From 1bf4f26df8bbc33497e231ebf927867787bd9a84 Mon Sep 17 00:00:00 2001 From: Denys Vuika Date: Thu, 19 Oct 2017 11:21:51 +0100 Subject: [PATCH] main application --- .angular-cli.json | 7 +- .editorconfig | 2 +- src/app/adf.module.ts | 25 +- src/app/app.component.scss | 4 + src/app/app.component.spec.ts | 46 +- src/app/app.component.ts | 60 +- src/app/app.module.ts | 65 +- src/app/app.routes.ts | 113 +- src/app/common/adf.module.ts | 44 + src/app/common/common.module.ts | 93 ++ .../dialogs/folder-dialog.component.html | 62 + .../dialogs/folder-dialog.component.spec.ts | 261 ++++ .../common/dialogs/folder-dialog.component.ts | 138 ++ .../common/dialogs/folder-name.validators.ts | 45 + .../folder-create.directive.spec.ts | 96 ++ .../directives/folder-create.directive.ts | 66 + .../directives/folder-edit.directive.spec.ts | 96 ++ .../directives/folder-edit.directive.ts | 67 + .../directives/node-copy.directive.spec.ts | 249 ++++ .../common/directives/node-copy.directive.ts | 125 ++ .../directives/node-delete.directive.spec.ts | 246 ++++ .../directives/node-delete.directive.ts | 237 ++++ .../node-download.directive.spec.ts | 142 ++ .../directives/node-download.directive.ts | 110 ++ .../node-favorite.directive.spec.ts | 332 +++++ .../directives/node-favorite.directive.ts | 181 +++ .../directives/node-move.directive.spec.ts | 299 +++++ .../common/directives/node-move.directive.ts | 210 +++ .../node-permanent-delete.directive.spec.ts | 339 +++++ .../node-permanent-delete.directive.ts | 194 +++ .../directives/node-restore.directive.spec.ts | 334 +++++ .../directives/node-restore.directive.ts | 259 ++++ src/app/common/material.module.ts | 41 + .../pipes/node-name-tooltip.pipe.spec.ts | 145 +++ .../common/pipes/node-name-tooltip.pipe.ts | 79 ++ .../services/browsing-files.service.spec.ts | 36 + .../common/services/browsing-files.service.ts | 26 + .../services/content-management.service.ts | 31 + .../services/node-actions.service.spec.ts | 1156 +++++++++++++++++ .../common/services/node-actions.service.ts | 580 +++++++++ .../current-user/current-user.component.html | 18 + .../current-user/current-user.component.scss | 36 + .../current-user.component.spec.ts | 66 + .../current-user/current-user.component.ts | 63 + .../favorites/favorites.component.html | 135 ++ .../favorites/favorites.component.spec.ts | 189 +++ .../favorites/favorites.component.ts | 100 ++ src/app/components/files/files.component.html | 149 +++ .../components/files/files.component.spec.ts | 427 ++++++ src/app/components/files/files.component.ts | 191 +++ .../components/header/header.component.html | 15 + .../components/header/header.component.scss | 57 + .../header/header.component.spec.ts | 89 ++ src/app/components/header/header.component.ts | 49 + .../components/layout/layout.component.html | 15 + .../components/layout/layout.component.scss | 8 + .../layout/layout.component.spec.ts | 104 ++ src/app/components/layout/layout.component.ts | 53 + .../libraries/libraries.component.html | 58 + .../libraries/libraries.component.spec.ts | 189 +++ .../libraries/libraries.component.ts | 80 ++ .../components/login/login.component.spec.ts | 192 +-- src/app/components/login/login.component.ts | 68 +- src/app/components/page.component.spec.ts | 295 +++++ src/app/components/page.component.ts | 124 ++ .../components/preview/preview.component.html | 3 + .../components/preview/preview.component.scss | 4 + .../components/preview/preview.component.ts | 56 + .../recent-files/recent-files.component.html | 122 ++ .../recent-files.component.spec.ts | 152 +++ .../recent-files/recent-files.component.ts | 75 ++ .../components/search/search.component.html | 6 + .../components/search/search.component.scss | 9 + .../search/search.component.spec.ts | 71 + src/app/components/search/search.component.ts | 45 + .../shared-files/shared-files.component.html | 130 ++ .../shared-files.component.spec.ts | 161 +++ .../shared-files/shared-files.component.ts | 88 ++ .../components/sidenav/sidenav.component.html | 68 + .../components/sidenav/sidenav.component.scss | 78 ++ .../sidenav/sidenav.component.spec.ts | 85 ++ .../components/sidenav/sidenav.component.ts | 105 ++ .../trashcan/trashcan.component.html | 89 ++ .../trashcan/trashcan.component.spec.ts | 77 ++ .../components/trashcan/trashcan.component.ts | 31 + src/app/ui/_layout.scss | 68 + src/app/ui/_variables-color.scss | 48 + src/app/ui/_variables.scss | 3 + src/app/ui/application.scss | 28 + .../ui/overrides/_alfresco-document-list.scss | 83 ++ .../ui/overrides/_alfresco-upload-button.scss | 44 + .../ui/overrides/_alfresco-upload-dialog.scss | 23 + .../overrides/_alfresco-upload-drag-area.scss | 72 + src/app/ui/overrides/_breadcrumb.scss | 5 + src/app/ui/overrides/_toolbar.scss | 36 + src/app/ui/theme.scss | 25 + src/assets/i18n/en.json | 201 +++ src/assets/i18n/ru.json | 125 ++ src/assets/images/alfresco-logo-white.svg | 129 ++ src/{styles.css => styles.scss} | 1 + 100 files changed, 11535 insertions(+), 222 deletions(-) create mode 100644 src/app/common/adf.module.ts create mode 100644 src/app/common/common.module.ts create mode 100644 src/app/common/dialogs/folder-dialog.component.html create mode 100644 src/app/common/dialogs/folder-dialog.component.spec.ts create mode 100644 src/app/common/dialogs/folder-dialog.component.ts create mode 100644 src/app/common/dialogs/folder-name.validators.ts create mode 100644 src/app/common/directives/folder-create.directive.spec.ts create mode 100644 src/app/common/directives/folder-create.directive.ts create mode 100644 src/app/common/directives/folder-edit.directive.spec.ts create mode 100644 src/app/common/directives/folder-edit.directive.ts create mode 100644 src/app/common/directives/node-copy.directive.spec.ts create mode 100644 src/app/common/directives/node-copy.directive.ts create mode 100644 src/app/common/directives/node-delete.directive.spec.ts create mode 100644 src/app/common/directives/node-delete.directive.ts create mode 100644 src/app/common/directives/node-download.directive.spec.ts create mode 100644 src/app/common/directives/node-download.directive.ts create mode 100644 src/app/common/directives/node-favorite.directive.spec.ts create mode 100644 src/app/common/directives/node-favorite.directive.ts create mode 100644 src/app/common/directives/node-move.directive.spec.ts create mode 100644 src/app/common/directives/node-move.directive.ts create mode 100644 src/app/common/directives/node-permanent-delete.directive.spec.ts create mode 100644 src/app/common/directives/node-permanent-delete.directive.ts create mode 100644 src/app/common/directives/node-restore.directive.spec.ts create mode 100644 src/app/common/directives/node-restore.directive.ts create mode 100644 src/app/common/material.module.ts create mode 100644 src/app/common/pipes/node-name-tooltip.pipe.spec.ts create mode 100644 src/app/common/pipes/node-name-tooltip.pipe.ts create mode 100644 src/app/common/services/browsing-files.service.spec.ts create mode 100644 src/app/common/services/browsing-files.service.ts create mode 100644 src/app/common/services/content-management.service.ts create mode 100644 src/app/common/services/node-actions.service.spec.ts create mode 100644 src/app/common/services/node-actions.service.ts create mode 100644 src/app/components/current-user/current-user.component.html create mode 100644 src/app/components/current-user/current-user.component.scss create mode 100644 src/app/components/current-user/current-user.component.spec.ts create mode 100644 src/app/components/current-user/current-user.component.ts create mode 100644 src/app/components/favorites/favorites.component.html create mode 100644 src/app/components/favorites/favorites.component.spec.ts create mode 100644 src/app/components/favorites/favorites.component.ts create mode 100644 src/app/components/files/files.component.html create mode 100644 src/app/components/files/files.component.spec.ts create mode 100644 src/app/components/files/files.component.ts create mode 100644 src/app/components/header/header.component.html create mode 100644 src/app/components/header/header.component.scss create mode 100644 src/app/components/header/header.component.spec.ts create mode 100644 src/app/components/header/header.component.ts create mode 100644 src/app/components/layout/layout.component.html create mode 100644 src/app/components/layout/layout.component.scss create mode 100644 src/app/components/layout/layout.component.spec.ts create mode 100644 src/app/components/layout/layout.component.ts create mode 100644 src/app/components/libraries/libraries.component.html create mode 100644 src/app/components/libraries/libraries.component.spec.ts create mode 100644 src/app/components/libraries/libraries.component.ts create mode 100644 src/app/components/page.component.spec.ts create mode 100644 src/app/components/page.component.ts create mode 100644 src/app/components/preview/preview.component.html create mode 100644 src/app/components/preview/preview.component.scss create mode 100644 src/app/components/preview/preview.component.ts create mode 100644 src/app/components/recent-files/recent-files.component.html create mode 100644 src/app/components/recent-files/recent-files.component.spec.ts create mode 100644 src/app/components/recent-files/recent-files.component.ts create mode 100644 src/app/components/search/search.component.html create mode 100644 src/app/components/search/search.component.scss create mode 100644 src/app/components/search/search.component.spec.ts create mode 100644 src/app/components/search/search.component.ts create mode 100644 src/app/components/shared-files/shared-files.component.html create mode 100644 src/app/components/shared-files/shared-files.component.spec.ts create mode 100644 src/app/components/shared-files/shared-files.component.ts create mode 100644 src/app/components/sidenav/sidenav.component.html create mode 100644 src/app/components/sidenav/sidenav.component.scss create mode 100644 src/app/components/sidenav/sidenav.component.spec.ts create mode 100644 src/app/components/sidenav/sidenav.component.ts create mode 100644 src/app/components/trashcan/trashcan.component.html create mode 100644 src/app/components/trashcan/trashcan.component.spec.ts create mode 100644 src/app/components/trashcan/trashcan.component.ts create mode 100644 src/app/ui/_layout.scss create mode 100644 src/app/ui/_variables-color.scss create mode 100644 src/app/ui/_variables.scss create mode 100644 src/app/ui/application.scss create mode 100644 src/app/ui/overrides/_alfresco-document-list.scss create mode 100644 src/app/ui/overrides/_alfresco-upload-button.scss create mode 100644 src/app/ui/overrides/_alfresco-upload-dialog.scss create mode 100644 src/app/ui/overrides/_alfresco-upload-drag-area.scss create mode 100644 src/app/ui/overrides/_breadcrumb.scss create mode 100644 src/app/ui/overrides/_toolbar.scss create mode 100644 src/app/ui/theme.scss create mode 100644 src/assets/i18n/en.json create mode 100644 src/assets/i18n/ru.json create mode 100644 src/assets/images/alfresco-logo-white.svg rename src/{styles.css => styles.scss} (91%) diff --git a/.angular-cli.json b/.angular-cli.json index a446cd5b7..6cdb561bf 100644 --- a/.angular-cli.json +++ b/.angular-cli.json @@ -30,7 +30,7 @@ "testTsconfig": "tsconfig.spec.json", "prefix": "app", "styles": [ - "styles.css" + "styles.scss" ], "scripts": [ "../node_modules/pdfjs-dist/build/pdf.js", @@ -41,6 +41,11 @@ "environments": { "dev": "environments/environment.ts", "prod": "environments/environment.prod.ts" + }, + "stylePreprocessorOptions": { + "includePaths": [ + "app/ui" + ] } } ], diff --git a/.editorconfig b/.editorconfig index 6e87a003d..9b7352176 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,7 +4,7 @@ root = true [*] charset = utf-8 indent_style = space -indent_size = 2 +indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true diff --git a/src/app/adf.module.ts b/src/app/adf.module.ts index 2034755b0..ffa572b7d 100644 --- a/src/app/adf.module.ts +++ b/src/app/adf.module.ts @@ -10,20 +10,19 @@ import { UploadModule } from 'ng2-alfresco-upload'; import { SearchModule } from 'ng2-alfresco-search'; export function modules() { - return [ - // ADF modules - CoreModule, - DataTableModule, - DocumentListModule, - LoginModule, - SearchModule, - UploadModule, - ViewerModule - ]; + return [ + CoreModule, + DataTableModule, + DocumentListModule, + LoginModule, + SearchModule, + UploadModule, + ViewerModule + ]; } @NgModule({ - imports: modules(), - exports: modules() + imports: modules(), + exports: modules() }) -export class AdfModule {} +export class AdfModule { } diff --git a/src/app/app.component.scss b/src/app/app.component.scss index e69de29bb..2453e2916 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -0,0 +1,4 @@ +:host { + display: flex; + flex: 1; +} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index bcbdf36b3..bae855fbc 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,27 +1,27 @@ import { TestBed, async } from '@angular/core/testing'; import { AppComponent } from './app.component'; describe('AppComponent', () => { - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ - AppComponent - ], - }).compileComponents(); - })); - it('should create the app', async(() => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.debugElement.componentInstance; - expect(app).toBeTruthy(); - })); - it(`should have as title 'app'`, async(() => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.debugElement.componentInstance; - expect(app.title).toEqual('app'); - })); - it('should render title in a h1 tag', async(() => { - const fixture = TestBed.createComponent(AppComponent); - fixture.detectChanges(); - const compiled = fixture.debugElement.nativeElement; - expect(compiled.querySelector('h1').textContent).toContain('Welcome to app!'); - })); + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + AppComponent + ], + }).compileComponents(); + })); + it('should create the app', async(() => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.debugElement.componentInstance; + expect(app).toBeTruthy(); + })); + it(`should have as title 'app'`, async(() => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.debugElement.componentInstance; + expect(app.title).toEqual('app'); + })); + it('should render title in a h1 tag', async(() => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.debugElement.nativeElement; + expect(compiled.querySelector('h1').textContent).toContain('Welcome to app!'); + })); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 82c93e10f..3328004b0 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -20,41 +20,41 @@ import { Router, ActivatedRoute, NavigationEnd } from '@angular/router'; import { TranslationService, PageTitleService } from 'ng2-alfresco-core'; @Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.scss'] + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'] }) export class AppComponent implements OnInit { - constructor( - private route: ActivatedRoute, - private router: Router, - private pageTitle: PageTitleService, - private translateService: TranslationService) { - } + constructor( + private route: ActivatedRoute, + private router: Router, + private pageTitle: PageTitleService, + private translateService: TranslationService) { + } - ngOnInit() { - const { router, pageTitle, route, translateService } = this; + ngOnInit() { + const { router, pageTitle, route, translateService } = this; - router - .events - .filter(event => event instanceof NavigationEnd) - .subscribe(() => { - let currentRoute = route.root; + router + .events + .filter(event => event instanceof NavigationEnd) + .subscribe(() => { + let currentRoute = route.root; - while (currentRoute.firstChild) { - currentRoute = currentRoute.firstChild; - } + while (currentRoute.firstChild) { + currentRoute = currentRoute.firstChild; + } - const snapshot: any = currentRoute.snapshot || {}; - const data: any = snapshot.data || {}; + const snapshot: any = currentRoute.snapshot || {}; + const data: any = snapshot.data || {}; - if (data.i18nTitle) { - translateService.get(data.i18nTitle).subscribe(title => { - pageTitle.setTitle(title); - }); - } else { - pageTitle.setTitle(data.title || ''); - } - }); - } + if (data.i18nTitle) { + translateService.get(data.i18nTitle).subscribe(title => { + pageTitle.setTitle(title); + }); + } else { + pageTitle.setTitle(data.title || ''); + } + }); + } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index da472df9c..1d3fb6ec9 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,26 +1,65 @@ import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; +import { TRANSLATION_PROVIDER } from 'ng2-alfresco-core'; import { AdfModule } from './adf.module'; +import { CommonModule } from './common/common.module'; +import { MaterialModule } from './common/material.module'; + import { AppComponent } from './app.component'; import { APP_ROUTES } from './app.routes'; import { LoginComponent } from './components/login/login.component'; +import { PreviewComponent } from './components/preview/preview.component'; +import { FilesComponent } from './components/files/files.component'; +import { FavoritesComponent } from './components/favorites/favorites.component'; +import { LibrariesComponent } from './components/libraries/libraries.component'; +import { RecentFilesComponent } from './components/recent-files/recent-files.component'; +import { SharedFilesComponent } from './components/shared-files/shared-files.component'; +import { TrashcanComponent } from './components/trashcan/trashcan.component'; +import { LayoutComponent } from './components/layout/layout.component'; +import { HeaderComponent } from './components/header/header.component'; +import { CurrentUserComponent } from './components/current-user/current-user.component'; +import { SearchComponent } from './components/search/search.component'; +import { SidenavComponent } from './components/sidenav/sidenav.component'; @NgModule({ - imports: [ - BrowserModule, - RouterModule.forRoot(APP_ROUTES, { - enableTracing: true - }), - AdfModule - ], - declarations: [ - AppComponent, - LoginComponent - ], - providers: [], - bootstrap: [AppComponent] + imports: [ + BrowserModule, + RouterModule.forRoot(APP_ROUTES, { + enableTracing: true + }), + AdfModule, + CommonModule, + MaterialModule + ], + declarations: [ + AppComponent, + LoginComponent, + LayoutComponent, + HeaderComponent, + CurrentUserComponent, + SearchComponent, + SidenavComponent, + FilesComponent, + FavoritesComponent, + LibrariesComponent, + RecentFilesComponent, + SharedFilesComponent, + TrashcanComponent, + PreviewComponent + ], + providers: [ + { + provide: TRANSLATION_PROVIDER, + multi: true, + useValue: { + name: 'app', + source: 'assets' + } + } + ], + bootstrap: [AppComponent] }) export class AppModule { } diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 7c65b7533..2cd07aba1 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -16,20 +16,111 @@ */ import { Routes } from '@angular/router'; +import { AuthGuardEcm } from 'ng2-alfresco-core'; + +import { LayoutComponent } from './components/layout/layout.component'; + +import { FilesComponent } from './components/files/files.component'; +import { FavoritesComponent } from './components/favorites/favorites.component'; +import { LibrariesComponent } from './components/libraries/libraries.component'; +import { RecentFilesComponent } from './components/recent-files/recent-files.component'; +import { SharedFilesComponent } from './components/shared-files/shared-files.component'; +import { TrashcanComponent } from './components/trashcan/trashcan.component'; import { LoginComponent } from './components/login/login.component'; +import { PreviewComponent } from './components/preview/preview.component'; export const APP_ROUTES: Routes = [ - { - path: '**', - redirectTo: '/login', - pathMatch: 'full' - }, - { - path: 'login', - component: LoginComponent, - data: { - title: 'Sign in' + { + path: '', + component: LayoutComponent, + children: [ + { + path: '', + redirectTo: `/personal-files`, + pathMatch: 'full' + }, + { + path: 'favorites', + component: FavoritesComponent, + data: { + i18nTitle: 'APP.BROWSE.FAVORITES.TITLE' + } + }, + { + path: 'libraries', + children: [{ + path: '', + component: LibrariesComponent, + data: { + i18nTitle: 'APP.BROWSE.LIBRARIES.TITLE' + } + }, { + path: ':id', + component: FilesComponent, + data: { + i18nTitle: 'APP.BROWSE.LIBRARIES.TITLE' + } + }] + }, + { + path: 'personal-files', + children: [{ + path: '', + component: FilesComponent, + data: { + i18nTitle: 'APP.BROWSE.PERSONAL.TITLE', + defaultNodeId: '-my-' + } + }, { + path: ':id', + component: FilesComponent, + data: { + i18nTitle: 'APP.BROWSE.PERSONAL.TITLE' + } + }] + }, + { + path: 'recent-files', + component: RecentFilesComponent, + data: { + i18nTitle: 'APP.BROWSE.RECENT.TITLE' + } + }, + { + path: 'shared', + component: SharedFilesComponent, + data: { + i18nTitle: 'APP.BROWSE.SHARED.TITLE' + } + }, + { + path: 'trashcan', + component: TrashcanComponent, + data: { + i18nTitle: 'APP.BROWSE.TRASHCAN.TITLE' + } + } + ], + canActivate: [ + AuthGuardEcm + ] + }, + { + path: 'preview/:nodeId', + component: PreviewComponent + }, + { + path: '**', + redirectTo: '/login', + pathMatch: 'full' + }, + { + path: 'login', + component: LoginComponent, + data: { + title: 'Sign in' + } } - } ]; + diff --git a/src/app/common/adf.module.ts b/src/app/common/adf.module.ts new file mode 100644 index 000000000..9698c33a5 --- /dev/null +++ b/src/app/common/adf.module.ts @@ -0,0 +1,44 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NgModule } from '@angular/core'; + +import { CoreModule } from 'ng2-alfresco-core'; +import { DataTableModule } from 'ng2-alfresco-datatable'; +import { DocumentListModule } from 'ng2-alfresco-documentlist'; +import { ViewerModule } from 'ng2-alfresco-viewer'; +import { UploadModule } from 'ng2-alfresco-upload'; +import { SearchModule } from 'ng2-alfresco-search'; +import { LoginModule } from 'ng2-alfresco-login'; + +export function modules() { + return [ + CoreModule, + DataTableModule, + DocumentListModule, + ViewerModule, + UploadModule, + SearchModule, + LoginModule + ]; +} + +@NgModule({ + imports: modules(), + exports: modules() +}) +export class AdfModule {} diff --git a/src/app/common/common.module.ts b/src/app/common/common.module.ts new file mode 100644 index 000000000..7dadb14f4 --- /dev/null +++ b/src/app/common/common.module.ts @@ -0,0 +1,93 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NgModule } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { AdfModule } from './adf.module'; +import { MaterialModule } from './material.module'; + +import { FolderDialogComponent } from './dialogs/folder-dialog.component'; + +import { FolderCreateDirective } from './directives/folder-create.directive'; +import { FolderEditDirective } from './directives/folder-edit.directive'; +import { NodeCopyDirective } from './directives/node-copy.directive'; +import { NodeDeleteDirective } from './directives/node-delete.directive'; +import { NodeMoveDirective } from './directives/node-move.directive'; +import { DownloadFileDirective } from './directives/node-download.directive'; +import { NodeRestoreDirective } from './directives/node-restore.directive'; +import { NodePermanentDeleteDirective } from './directives/node-permanent-delete.directive'; +import { NodeFavoriteDirective } from './directives/node-favorite.directive'; + +import { NodeNameTooltipPipe } from './pipes/node-name-tooltip.pipe'; + +import { ContentManagementService } from './services/content-management.service'; +import { BrowsingFilesService } from './services/browsing-files.service'; +import { NodeActionsService } from './services/node-actions.service'; + +export function modules() { + return [ + FormsModule, + ReactiveFormsModule, + MaterialModule, + RouterModule, + AdfModule + ]; +} + +export function declarations() { + return [ + FolderDialogComponent, + + FolderCreateDirective, + FolderEditDirective, + NodeCopyDirective, + NodeDeleteDirective, + NodeMoveDirective, + DownloadFileDirective, + NodeRestoreDirective, + NodePermanentDeleteDirective, + NodeFavoriteDirective, + + NodeNameTooltipPipe + ]; +} + +export function providers() { + return [ + DatePipe, + BrowsingFilesService, + ContentManagementService, + NodeActionsService + ]; +} + +@NgModule({ + imports: modules(), + declarations: declarations(), + entryComponents: [ + FolderDialogComponent + ], + providers: providers(), + exports: [ + ...modules(), + ...declarations() + ] +}) +export class CommonModule {} diff --git a/src/app/common/dialogs/folder-dialog.component.html b/src/app/common/dialogs/folder-dialog.component.html new file mode 100644 index 000000000..c9777e6ae --- /dev/null +++ b/src/app/common/dialogs/folder-dialog.component.html @@ -0,0 +1,62 @@ +

+ {{ + (editing + ? 'APP.FOLDER_DIALOG.EDIT_FOLDER_TITLE' + : 'APP.FOLDER_DIALOG.CREATE_FOLDER_TITLE' + ) | translate + }} +

+ + +
+ + + + + + {{ 'APP.FOLDER_DIALOG.FOLDER_NAME.ERRORS.REQUIRED' | translate }} + + + + {{ form.controls['name'].errors?.message | translate }} + + + + +
+
+ + + + +
+
+ + + + + + \ No newline at end of file diff --git a/src/app/common/dialogs/folder-dialog.component.spec.ts b/src/app/common/dialogs/folder-dialog.component.spec.ts new file mode 100644 index 000000000..038ee6f80 --- /dev/null +++ b/src/app/common/dialogs/folder-dialog.component.spec.ts @@ -0,0 +1,261 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed, async } from '@angular/core/testing'; +import { Observable } from 'rxjs/Rx'; +import { MdDialogModule, MdDialogRef } from '@angular/material'; +import { CoreModule, NodesApiService, TranslationService, NotificationService } from 'ng2-alfresco-core'; + +import {BrowserDynamicTestingModule} from '@angular/platform-browser-dynamic/testing'; +import { FolderDialogComponent } from './folder-dialog.component'; +import { ComponentFixture } from '@angular/core/testing'; + +describe('FolderDialogComponent', () => { + + let dialogRefMock; + let fixture: ComponentFixture; + let component: FolderDialogComponent; + let translationService: TranslationService; + let nodesApi: NodesApiService; + let notificationService: NotificationService; + let dialogRef; + + beforeEach(async(() => { + dialogRef = { + close: jasmine.createSpy('close') + }; + + TestBed.configureTestingModule({ + imports: [ + CoreModule, + MdDialogModule + ], + declarations: [ + FolderDialogComponent + ], + providers: [ + { provide: MdDialogRef, useValue: dialogRef } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FolderDialogComponent); + component = fixture.componentInstance; + + nodesApi = TestBed.get(NodesApiService); + notificationService = TestBed.get(NotificationService); + + translationService = TestBed.get(TranslationService); + spyOn(translationService, 'get').and.returnValue(Observable.of('message')); + }); + + describe('Edit', () => { + + beforeEach(() => { + component.data = { + folder: { + id: 'node-id', + name: 'folder-name', + properties: { + ['cm:description']: 'folder-description' + } + } + }; + component.ngOnInit(); + }); + + it('should init form with folder name and description', () => { + expect(component.name).toBe('folder-name'); + expect(component.description).toBe('folder-description'); + }); + + it('should update form input', () => { + component.form.controls['name'].setValue('folder-name-update'); + component.form.controls['description'].setValue('folder-description-update'); + + expect(component.name).toBe('folder-name-update'); + expect(component.description).toBe('folder-description-update'); + }); + + it('should submit updated values if form is valid', () => { + spyOn(nodesApi, 'updateNode').and.returnValue(Observable.of({})); + + component.form.controls['name'].setValue('folder-name-update'); + component.form.controls['description'].setValue('folder-description-update'); + + component.submit(); + + expect(nodesApi.updateNode).toHaveBeenCalledWith( + 'node-id', + { + name: 'folder-name-update', + properties: { + 'cm:title': 'folder-name-update', + 'cm:description': 'folder-description-update' + } + } + ); + }); + + it('should call dialog to close with form data when submit is succesfluly', () => { + const folder = { + data: 'folder-data' + }; + + spyOn(nodesApi, 'updateNode').and.returnValue(Observable.of(folder)); + + component.submit(); + + expect(dialogRef.close).toHaveBeenCalledWith(folder); + }); + + it('should not submit if form is invalid', () => { + spyOn(nodesApi, 'updateNode'); + + component.form.controls['name'].setValue(''); + component.form.controls['description'].setValue(''); + + component.submit(); + + expect(component.form.valid).toBe(false); + expect(nodesApi.updateNode).not.toHaveBeenCalled(); + }); + + it('should not call dialog to close if submit fails', () => { + spyOn(nodesApi, 'updateNode').and.returnValue(Observable.throw('error')); + spyOn(component, 'handleError').and.callFake(val => val); + + component.submit(); + + expect(component.handleError).toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + }); + + describe('Create', () => { + beforeEach(() => { + component.data = { + parentNodeId: 'parentNodeId', + folder: null + }; + component.ngOnInit(); + }); + + it('should init form with empty inputs', () => { + expect(component.name).toBe(''); + expect(component.description).toBe(''); + }); + + it('should update form input', () => { + component.form.controls['name'].setValue('folder-name-update'); + component.form.controls['description'].setValue('folder-description-update'); + + expect(component.name).toBe('folder-name-update'); + expect(component.description).toBe('folder-description-update'); + }); + + it('should submit updated values if form is valid', () => { + spyOn(nodesApi, 'createFolder').and.returnValue(Observable.of({})); + + component.form.controls['name'].setValue('folder-name-update'); + component.form.controls['description'].setValue('folder-description-update'); + + component.submit(); + + expect(nodesApi.createFolder).toHaveBeenCalledWith( + 'parentNodeId', + { + name: 'folder-name-update', + properties: { + 'cm:title': 'folder-name-update', + 'cm:description': 'folder-description-update' + } + } + ); + }); + + it('should call dialog to close with form data when submit is succesfluly', () => { + const folder = { + data: 'folder-data' + }; + + component.form.controls['name'].setValue('name'); + component.form.controls['description'].setValue('description'); + + spyOn(nodesApi, 'createFolder').and.returnValue(Observable.of(folder)); + + component.submit(); + + expect(dialogRef.close).toHaveBeenCalledWith(folder); + }); + + it('should not submit if form is invalid', () => { + spyOn(nodesApi, 'createFolder'); + + component.form.controls['name'].setValue(''); + component.form.controls['description'].setValue(''); + + component.submit(); + + expect(component.form.valid).toBe(false); + expect(nodesApi.createFolder).not.toHaveBeenCalled(); + }); + + it('should not call dialog to close if submit fails', () => { + spyOn(nodesApi, 'createFolder').and.returnValue(Observable.throw('error')); + spyOn(component, 'handleError').and.callFake(val => val); + + component.form.controls['name'].setValue('name'); + component.form.controls['description'].setValue('description'); + + component.submit(); + + expect(component.handleError).toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + }); + + describe('handleError()', () => { + it('should raise error for 409', () => { + spyOn(notificationService, 'openSnackMessage').and.stub(); + + const error = { + message: '{ "error": { "statusCode" : 409 } }' + }; + + component.handleError(error); + + expect(notificationService.openSnackMessage).toHaveBeenCalled(); + expect(translationService.get).toHaveBeenCalledWith('APP.MESSAGES.ERRORS.EXISTENT_FOLDER'); + }); + + it('should raise generic error', () => { + spyOn(notificationService, 'openSnackMessage').and.stub(); + + const error = { + message: '{ "error": { "statusCode" : 123 } }' + }; + + component.handleError(error); + + expect(notificationService.openSnackMessage).toHaveBeenCalled(); + expect(translationService.get).toHaveBeenCalledWith('APP.MESSAGES.ERRORS.GENERIC'); + }); + }); +}); diff --git a/src/app/common/dialogs/folder-dialog.component.ts b/src/app/common/dialogs/folder-dialog.component.ts new file mode 100644 index 000000000..36423498a --- /dev/null +++ b/src/app/common/dialogs/folder-dialog.component.ts @@ -0,0 +1,138 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Observable } from 'rxjs/Rx'; + +import { Component, Inject, Optional } from '@angular/core'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { MdDialogRef, MD_DIALOG_DATA } from '@angular/material'; + +import { TranslationService, NodesApiService, NotificationService } from 'ng2-alfresco-core'; +import { MinimalNodeEntryEntity } from 'alfresco-js-api'; + +import { forbidSpecialCharacters, forbidEndingDot, forbidOnlySpaces } from './folder-name.validators'; + +@Component({ + selector: 'folder-dialog', + templateUrl: './folder-dialog.component.html' +}) +export class FolderDialogComponent { + form: FormGroup; + folder: MinimalNodeEntryEntity = null; + + constructor( + private formBuilder: FormBuilder, + private dialog: MdDialogRef, + private nodesApi: NodesApiService, + private translation: TranslationService, + private notification: NotificationService, + @Optional() + @Inject(MD_DIALOG_DATA) + public data: any + ) {} + + get editing(): boolean { + return !!this.data.folder; + } + + ngOnInit() { + const { folder } = this.data; + let name = '', description = ''; + + if (folder) { + const { properties } = folder; + + name = folder.name || ''; + description = properties ? properties['cm:description'] : ''; + } + + const validators = { + name: [ + Validators.required, + forbidSpecialCharacters, + forbidEndingDot, + forbidOnlySpaces + ] + }; + + this.form = this.formBuilder.group({ + name: [ name, validators.name ], + description: [ description ] + }); + } + + get name(): string { + let { name } = this.form.value; + + return (name || '').trim(); + } + + get description(): string { + let { description } = this.form.value; + + return (description || '').trim(); + } + + private get properties(): any { + const { name: title, description } = this; + + return { + 'cm:title': title, + 'cm:description': description + }; + } + + private create(): Observable { + const { name, properties, nodesApi, data: { parentNodeId} } = this; + return nodesApi.createFolder(parentNodeId, { name, properties }); + } + + private edit(): Observable { + const { name, properties, nodesApi, data: { folder: { id: nodeId }} } = this; + return nodesApi.updateNode(nodeId, { name, properties }); + } + + submit() { + const { form, dialog, editing } = this; + + if (!form.valid) { return; } + + (editing ? this.edit() : this.create()) + .subscribe( + (folder: MinimalNodeEntryEntity) => dialog.close(folder), + (error) => this.handleError(error) + ); + } + + handleError(error: any): any { + let i18nMessageString = 'APP.MESSAGES.ERRORS.GENERIC'; + + try { + const { error: { statusCode } } = JSON.parse(error.message); + + if (statusCode === 409) { + i18nMessageString = 'APP.MESSAGES.ERRORS.EXISTENT_FOLDER'; + } + } catch (err) { /* Do nothing, keep the original message */ } + + this.translation.get(i18nMessageString).subscribe(message => { + this.notification.openSnackMessage(message, 3000); + }); + + return error; + } +} diff --git a/src/app/common/dialogs/folder-name.validators.ts b/src/app/common/dialogs/folder-name.validators.ts new file mode 100644 index 000000000..714ac127f --- /dev/null +++ b/src/app/common/dialogs/folder-name.validators.ts @@ -0,0 +1,45 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FormControl } from '@angular/forms'; + +const I18N_ERRORS_PATH = 'APP.FOLDER_DIALOG.FOLDER_NAME.ERRORS'; + +export function forbidSpecialCharacters({ value }: FormControl) { + const specialCharacters: RegExp = /([\*\"\<\>\\\/\?\:\|])/; + const isValid: boolean = !specialCharacters.test(value); + + return (isValid) ? null : { + message: `${I18N_ERRORS_PATH}.SPECIAL_CHARACTERS` + }; +} + +export function forbidEndingDot({ value }: FormControl) { + const isValid: boolean = ((value || '').split('').pop() !== '.'); + + return isValid ? null : { + message: `${I18N_ERRORS_PATH}.ENDING_DOT` + }; +} + +export function forbidOnlySpaces({ value }: FormControl) { + const isValid: boolean = !!((value || '')).trim(); + + return isValid ? null : { + message: `${I18N_ERRORS_PATH}.ONLY_SPACES` + }; +} diff --git a/src/app/common/directives/folder-create.directive.spec.ts b/src/app/common/directives/folder-create.directive.spec.ts new file mode 100644 index 000000000..689b75e37 --- /dev/null +++ b/src/app/common/directives/folder-create.directive.spec.ts @@ -0,0 +1,96 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Component } from '@angular/core'; +import { Observable } from 'rxjs/Rx'; +import { MdDialogModule, MdDialog } from '@angular/material'; + +import { FolderCreateDirective } from './folder-create.directive'; +import { ContentManagementService } from '../services/content-management.service'; + +@Component({ + template: '
' +}) +class TestComponent { + parentNode = ''; +} + +describe('FolderCreateDirective', () => { + let fixture: ComponentFixture; + let element; + let node: any; + let dialog: MdDialog; + let contentService: ContentManagementService; + let dialogRefMock; + + const event: any = { + type: 'click', + preventDefault: () => null + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ MdDialogModule ], + declarations: [ + TestComponent, + FolderCreateDirective + ] + , + providers: [ + ContentManagementService + ] + }); + + fixture = TestBed.createComponent(TestComponent); + element = fixture.debugElement.query(By.directive(FolderCreateDirective)); + dialog = TestBed.get(MdDialog); + contentService = TestBed.get(ContentManagementService); + }); + + beforeEach(() => { + node = { entry: { id: 'nodeId' } }; + + dialogRefMock = { + afterClosed: val => Observable.of(val) + }; + + spyOn(dialog, 'open').and.returnValue(dialogRefMock); + }); + + it('emits createFolder event when input value is not undefined', () => { + spyOn(dialogRefMock, 'afterClosed').and.returnValue(Observable.of(node)); + + contentService.createFolder.subscribe((val) => { + expect(val).toBe(node); + }); + + element.triggerEventHandler('click', event); + fixture.detectChanges(); + }); + + it('does not emits createFolder event when input value is undefined', () => { + spyOn(dialogRefMock, 'afterClosed').and.returnValue(Observable.of(null)); + spyOn(contentService.createFolder, 'next'); + + element.triggerEventHandler('click', event); + fixture.detectChanges(); + + expect(contentService.createFolder.next).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/common/directives/folder-create.directive.ts b/src/app/common/directives/folder-create.directive.ts new file mode 100644 index 000000000..492124b55 --- /dev/null +++ b/src/app/common/directives/folder-create.directive.ts @@ -0,0 +1,66 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Directive, HostListener, Input } from '@angular/core'; +import { MdDialog, MdDialogConfig } from '@angular/material'; + +import { MinimalNodeEntryEntity } from 'alfresco-js-api'; + +import { FolderDialogComponent } from '../dialogs/folder-dialog.component'; +import { ContentManagementService } from '../services/content-management.service'; + +@Directive({ + selector: '[app-create-folder]' +}) +export class FolderCreateDirective { + static DIALOG_WIDTH: number = 400; + + @Input('app-create-folder') + parentNodeId: string; + + @HostListener('click', [ '$event' ]) + onClick(event) { + event.preventDefault(); + this.openDialog(); + } + + constructor( + public dialogRef: MdDialog, + public content: ContentManagementService + ) {} + + private get dialogConfig(): MdDialogConfig { + const { DIALOG_WIDTH: width } = FolderCreateDirective; + const { parentNodeId } = this; + + return { + data: { parentNodeId }, + width: `${width}px` + }; + } + + private openDialog(): void { + const { dialogRef, dialogConfig, content } = this; + const dialogInstance = dialogRef.open(FolderDialogComponent, dialogConfig); + + dialogInstance.afterClosed().subscribe((node: MinimalNodeEntryEntity) => { + if (node) { + content.createFolder.next(node); + } + }); + } +} diff --git a/src/app/common/directives/folder-edit.directive.spec.ts b/src/app/common/directives/folder-edit.directive.spec.ts new file mode 100644 index 000000000..2b9013f4c --- /dev/null +++ b/src/app/common/directives/folder-edit.directive.spec.ts @@ -0,0 +1,96 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Component } from '@angular/core'; +import { Observable } from 'rxjs/Rx'; +import { MdDialogModule, MdDialog } from '@angular/material'; + +import { FolderEditDirective } from './folder-edit.directive'; +import { ContentManagementService } from '../services/content-management.service'; + +@Component({ + template: '
' +}) +class TestComponent { + folder = {}; +} + +describe('FolderEditDirective', () => { + let fixture: ComponentFixture; + let element; + let node: any; + let dialog: MdDialog; + let contentService: ContentManagementService; + let dialogRefMock; + + const event = { + type: 'click', + preventDefault: () => null + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ MdDialogModule ], + declarations: [ + TestComponent, + FolderEditDirective + ] + , + providers: [ + ContentManagementService + ] + }); + + fixture = TestBed.createComponent(TestComponent); + element = fixture.debugElement.query(By.directive(FolderEditDirective)); + dialog = TestBed.get(MdDialog); + contentService = TestBed.get(ContentManagementService); + }); + + beforeEach(() => { + node = { entry: { id: 'folderId' } }; + + dialogRefMock = { + afterClosed: val => Observable.of(val) + }; + + spyOn(dialog, 'open').and.returnValue(dialogRefMock); + }); + + it('emits editFolder event when input value is not undefined', () => { + spyOn(dialogRefMock, 'afterClosed').and.returnValue(Observable.of(node)); + + contentService.createFolder.subscribe((val) => { + expect(val).toBe(node); + }); + + element.triggerEventHandler('click', event); + fixture.detectChanges(); + }); + + it('does not emits FolderEditDirective event when input value is undefined', () => { + spyOn(dialogRefMock, 'afterClosed').and.returnValue(Observable.of(null)); + spyOn(contentService.createFolder, 'next'); + + element.triggerEventHandler('click', event); + fixture.detectChanges(); + + expect(contentService.createFolder.next).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/common/directives/folder-edit.directive.ts b/src/app/common/directives/folder-edit.directive.ts new file mode 100644 index 000000000..89cc06f03 --- /dev/null +++ b/src/app/common/directives/folder-edit.directive.ts @@ -0,0 +1,67 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Directive, HostListener, ElementRef, Input } from '@angular/core'; +import { MdDialog, MdDialogConfig } from '@angular/material'; + +import { MinimalNodeEntryEntity } from 'alfresco-js-api'; + +import { FolderDialogComponent } from '../dialogs/folder-dialog.component'; +import { ContentManagementService } from '../services/content-management.service'; + +@Directive({ + selector: '[app-edit-folder]' +}) +export class FolderEditDirective { + static DIALOG_WIDTH: number = 400; + + @Input('app-edit-folder') + folder: MinimalNodeEntryEntity; + + @HostListener('click', [ '$event' ]) + onClick(event) { + event.preventDefault(); + this.openDialog(); + } + + constructor( + public dialogRef: MdDialog, + public elementRef: ElementRef, + public content: ContentManagementService + ) {} + + private get dialogConfig(): MdDialogConfig { + const { DIALOG_WIDTH: width } = FolderEditDirective; + const { folder } = this; + + return { + data: { folder }, + width: `${width}px` + }; + } + + private openDialog(): void { + const { dialogRef, dialogConfig, content } = this; + const dialogInstance = dialogRef.open(FolderDialogComponent, dialogConfig); + + dialogInstance.afterClosed().subscribe((node: MinimalNodeEntryEntity) => { + if (node) { + content.editFolder.next(node); + } + }); + } +} diff --git a/src/app/common/directives/node-copy.directive.spec.ts b/src/app/common/directives/node-copy.directive.spec.ts new file mode 100644 index 000000000..f17390936 --- /dev/null +++ b/src/app/common/directives/node-copy.directive.spec.ts @@ -0,0 +1,249 @@ +import { Component, DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { Observable } from 'rxjs/Rx'; + +import { CoreModule, TranslationService, NodesApiService, NotificationService } from 'ng2-alfresco-core'; +import { DocumentListModule } from 'ng2-alfresco-documentlist'; + +import { NodeActionsService } from '../services/node-actions.service'; +import { ContentManagementService } from '../services/content-management.service'; +import { NodeCopyDirective } from './node-copy.directive'; + +@Component({ + template: '
' +}) +class TestComponent { + selection; +} + +describe('NodeCopyDirective', () => { + let fixture: ComponentFixture; + let component: TestComponent; + let element: DebugElement; + let notificationService: NotificationService; + let nodesApiService: NodesApiService; + let service: NodeActionsService; + let translationService: TranslationService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CoreModule, + DocumentListModule + ], + declarations: [ + TestComponent, + NodeCopyDirective + ], + providers: [ + ContentManagementService, + NodeActionsService + ] + }); + + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + element = fixture.debugElement.query(By.directive(NodeCopyDirective)); + notificationService = TestBed.get(NotificationService); + nodesApiService = TestBed.get(NodesApiService); + service = TestBed.get(NodeActionsService); + translationService = TestBed.get(TranslationService); + }); + + beforeEach(() => { + spyOn(translationService, 'get').and.callFake((key) => { + return Observable.of(key); + }); + }); + + describe('Copy node action', () => { + beforeEach(() => { + spyOn(notificationService, 'openSnackMessageAction').and.callThrough(); + }); + + it('notifies successful copy of a node', () => { + spyOn(service, 'copyNodes').and.returnValue(Observable.of('OPERATION.SUCCES.CONTENT.COPY')); + + component.selection = [{ entry: { id: 'node-to-copy-id', name: 'name' } }]; + const createdItems = [{ entry: { id: 'copy-id', name: 'name' } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentCopied.next(createdItems); + + expect(service.copyNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.NODE_COPY.SINGULAR', 'Undo', 10000 + ); + }); + + it('notifies successful copy of multiple nodes', () => { + spyOn(service, 'copyNodes').and.returnValue(Observable.of('OPERATION.SUCCES.CONTENT.COPY')); + + component.selection = [ + { entry: { id: 'node-to-copy-1', name: 'name1' } }, + { entry: { id: 'node-to-copy-2', name: 'name2' } }]; + const createdItems = [ + { entry: { id: 'copy-of-node-1', name: 'name1' } }, + { entry: { id: 'copy-of-node-2', name: 'name2' } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentCopied.next(createdItems); + + expect(service.copyNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.NODE_COPY.PLURAL', 'Undo', 10000 + ); + }); + + it('notifies error if success message was not emitted', () => { + spyOn(service, 'copyNodes').and.returnValue(Observable.of('')); + + component.selection = [{ entry: { id: 'node-to-copy-id', name: 'name' } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentCopied.next(); + + expect(service.copyNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith('APP.MESSAGES.ERRORS.GENERIC', '', 3000); + }); + + it('notifies permission error on copy of node', () => { + spyOn(service, 'copyNodes').and.returnValue(Observable.throw(new Error(JSON.stringify({error: {statusCode: 403}})))); + + component.selection = [{ entry: { id: '1', name: 'name' } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(service.copyNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.ERRORS.PERMISSION', '', 3000 + ); + }); + + it('notifies generic error message on all errors, but 403', () => { + spyOn(service, 'copyNodes').and.returnValue(Observable.throw(new Error(JSON.stringify({error: {statusCode: 404}})))); + + component.selection = [{ entry: { id: '1', name: 'name' } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(service.copyNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.ERRORS.GENERIC', '', 3000 + ); + }); + }); + + describe('Undo Copy action', () => { + beforeEach(() => { + spyOn(service, 'copyNodes').and.returnValue(Observable.of('OPERATION.SUCCES.CONTENT.COPY')); + + spyOn(notificationService, 'openSnackMessageAction').and.returnValue({ + onAction: () => Observable.of({}) + }); + }); + + it('should delete the newly created node on Undo action', () => { + spyOn(nodesApiService, 'deleteNode').and.returnValue(Observable.of(null)); + + component.selection = [{ entry: { id: 'node-to-copy-id', name: 'name' } }]; + const createdItems = [{ entry: { id: 'copy-id', name: 'name' } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentCopied.next(createdItems); + + expect(service.copyNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.NODE_COPY.SINGULAR', 'Undo', 10000 + ); + + expect(nodesApiService.deleteNode).toHaveBeenCalledWith(createdItems[0].entry.id, { permanent: true }); + }); + + it('should delete also the node created inside an already existing folder from destination', () => { + const spyOnDeleteNode = spyOn(nodesApiService, 'deleteNode').and.returnValue(Observable.of(null)); + + component.selection = [ + { entry: { id: 'node-to-copy-1', name: 'name1' } }, + { entry: { id: 'node-to-copy-2', name: 'folder-with-name-already-existing-on-destination' } }]; + const id1 = 'copy-of-node-1'; + const id2 = 'copy-of-child-of-node-2'; + const createdItems = [ + { entry: { id: id1, name: 'name1' } }, + [ { entry: { id: id2, name: 'name-of-child-of-node-2' , parentId: 'the-folder-already-on-destination' } }] ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentCopied.next(createdItems); + + expect(service.copyNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.NODE_COPY.PLURAL', 'Undo', 10000 + ); + + expect(spyOnDeleteNode).toHaveBeenCalled(); + expect(spyOnDeleteNode.calls.allArgs()) + .toEqual([[id1, { permanent: true }], [id2, { permanent: true }]]); + }); + + it('notifies when error occurs on Undo action', () => { + spyOn(nodesApiService, 'deleteNode').and.returnValue(Observable.throw(null)); + + component.selection = [{ entry: { id: 'node-to-copy-id', name: 'name' } }]; + const createdItems = [{ entry: { id: 'copy-id', name: 'name' } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentCopied.next(createdItems); + + expect(service.copyNodes).toHaveBeenCalled(); + expect(nodesApiService.deleteNode).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction['calls'].allArgs()) + .toEqual([['APP.MESSAGES.INFO.NODE_COPY.SINGULAR', 'Undo', 10000], + ['APP.MESSAGES.ERRORS.GENERIC', '', 3000]]); + }); + + it('notifies when some error of type Error occurs on Undo action', () => { + spyOn(nodesApiService, 'deleteNode').and.returnValue(Observable.throw(new Error('oops!'))); + + component.selection = [{ entry: { id: 'node-to-copy-id', name: 'name' } }]; + const createdItems = [{ entry: { id: 'copy-id', name: 'name' } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentCopied.next(createdItems); + + expect(service.copyNodes).toHaveBeenCalled(); + expect(nodesApiService.deleteNode).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction['calls'].allArgs()) + .toEqual([['APP.MESSAGES.INFO.NODE_COPY.SINGULAR', 'Undo', 10000], + ['APP.MESSAGES.ERRORS.GENERIC', '', 3000]]); + }); + + it('notifies permission error when it occurs on Undo action', () => { + spyOn(nodesApiService, 'deleteNode').and.returnValue(Observable.throw(new Error(JSON.stringify({error: {statusCode: 403}})))); + + component.selection = [{ entry: { id: 'node-to-copy-id', name: 'name' } }]; + const createdItems = [{ entry: { id: 'copy-id', name: 'name' } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentCopied.next(createdItems); + + expect(service.copyNodes).toHaveBeenCalled(); + expect(nodesApiService.deleteNode).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction['calls'].allArgs()) + .toEqual([['APP.MESSAGES.INFO.NODE_COPY.SINGULAR', 'Undo', 10000], + ['APP.MESSAGES.ERRORS.PERMISSION', '', 3000]]); + }); + }); + +}); diff --git a/src/app/common/directives/node-copy.directive.ts b/src/app/common/directives/node-copy.directive.ts new file mode 100644 index 000000000..23c1b41e3 --- /dev/null +++ b/src/app/common/directives/node-copy.directive.ts @@ -0,0 +1,125 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Directive, HostListener, Input } from '@angular/core'; +import { Observable } from 'rxjs/Rx'; + +import { TranslationService, NodesApiService, NotificationService } from 'ng2-alfresco-core'; +import { MinimalNodeEntity } from 'alfresco-js-api'; +import { NodeActionsService } from '../services/node-actions.service'; +import { ContentManagementService } from '../services/content-management.service'; + +@Directive({ + selector: '[app-copy-node]' +}) +export class NodeCopyDirective { + + @Input('app-copy-node') + selection: MinimalNodeEntity[]; + + @HostListener('click') + onClick() { + this.copySelected(); + } + + constructor( + private content: ContentManagementService, + private notification: NotificationService, + private nodeActionsService: NodeActionsService, + private nodesApi: NodesApiService, + private translation: TranslationService + ) {} + + copySelected() { + Observable.zip( + this.nodeActionsService.copyNodes(this.selection), + this.nodeActionsService.contentCopied + ).subscribe( + (result) => { + const [ operationResult, newItems ] = result; + this.toastMessage(operationResult, newItems); + }, + (error) => { + this.toastMessage(error); + } + ); + } + + private toastMessage(info: any, newItems?: MinimalNodeEntity[]) { + const numberOfCopiedItems = newItems ? newItems.length : ''; + + let i18nMessageString = 'APP.MESSAGES.ERRORS.GENERIC'; + + if (typeof info === 'string') { + if (info.toLowerCase().indexOf('succes') !== -1) { + + const i18MessageSuffix = ( numberOfCopiedItems === 1 ) ? 'SINGULAR' : 'PLURAL'; + i18nMessageString = `APP.MESSAGES.INFO.NODE_COPY.${i18MessageSuffix}`; + } + + } else { + try { + + const { error: { statusCode } } = JSON.parse(info.message); + + if (statusCode === 403) { + i18nMessageString = 'APP.MESSAGES.ERRORS.PERMISSION'; + } + + } catch (err) { /* Do nothing, keep the original message */ } + } + + const undo = (numberOfCopiedItems > 0) ? 'Undo' : ''; + const withUndo = (numberOfCopiedItems > 0) ? '_WITH_UNDO' : ''; + + this.translation.get(i18nMessageString, { number: numberOfCopiedItems }).subscribe(message => { + this.notification.openSnackMessageAction(message, undo, NodeActionsService[`SNACK_MESSAGE_DURATION${withUndo}`]) + .onAction() + .subscribe(() => this.deleteCopy(newItems)); + }); + } + + private deleteCopy(nodes: MinimalNodeEntity[]) { + const batch = this.nodeActionsService.flatten(nodes) + .filter(item => item.entry) + .map(item => this.nodesApi.deleteNode(item.entry.id, { permanent: true })); + + Observable.forkJoin(...batch) + .subscribe( + () => { + this.content.deleteNode.next(null); + }, + (error) => { + let i18nMessageString = 'APP.MESSAGES.ERRORS.GENERIC'; + + let errorJson = null; + try { + errorJson = JSON.parse(error.message); + } catch (e) { // + } + + if (errorJson && errorJson.error && errorJson.error.statusCode === 403) { + i18nMessageString = 'APP.MESSAGES.ERRORS.PERMISSION'; + } + + this.translation.get(i18nMessageString).subscribe(message => { + this.notification.openSnackMessageAction(message, '', NodeActionsService.SNACK_MESSAGE_DURATION); + }); + } + ); + } +} diff --git a/src/app/common/directives/node-delete.directive.spec.ts b/src/app/common/directives/node-delete.directive.spec.ts new file mode 100644 index 000000000..cf91a10ac --- /dev/null +++ b/src/app/common/directives/node-delete.directive.spec.ts @@ -0,0 +1,246 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed, ComponentFixture, fakeAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { CoreModule, TranslationService, NodesApiService, NotificationService } from 'ng2-alfresco-core'; +import { Component, DebugElement } from '@angular/core'; +import { NodeDeleteDirective } from './node-delete.directive'; +import { ContentManagementService } from '../services/content-management.service'; +import { Observable } from 'rxjs/Rx'; + +@Component({ + template: '
' +}) +class TestComponent { + selection; +} + +describe('NodeDeleteDirective', () => { + let component: TestComponent; + let fixture: ComponentFixture; + let element: DebugElement; + let notificationService: NotificationService; + let translationService: TranslationService; + let contentService: ContentManagementService; + let nodeApiService: NodesApiService; + let spySnackBar; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CoreModule + ], + declarations: [ + TestComponent, + NodeDeleteDirective + ], + providers: [ + ContentManagementService + ] + }); + + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + element = fixture.debugElement.query(By.directive(NodeDeleteDirective)); + notificationService = TestBed.get(NotificationService); + translationService = TestBed.get(TranslationService); + nodeApiService = TestBed.get(NodesApiService); + contentService = TestBed.get(ContentManagementService); + }); + + beforeEach(() => { + spyOn(translationService, 'get').and.callFake((key) => { + return Observable.of(key); + }); + }); + + describe('Delete action', () => { + beforeEach(() => { + spyOn(notificationService, 'openSnackMessageAction').and.callThrough(); + }); + + it('notifies file deletion', () => { + spyOn(nodeApiService, 'deleteNode').and.returnValue(Observable.of(null)); + + component.selection = [{ entry: { id: '1', name: 'name1' } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.NODE_DELETION.SINGULAR', 'Undo', 10000 + ); + }); + + it('notifies faild file deletion', () => { + spyOn(nodeApiService, 'deleteNode').and.returnValue(Observable.throw(null)); + + component.selection = [{ entry: { id: '1', name: 'name1' } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.ERRORS.NODE_DELETION', '', 10000 + ); + }); + + it('notifies files deletion', () => { + spyOn(nodeApiService, 'deleteNode').and.returnValue(Observable.of(null)); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.NODE_DELETION.PLURAL', 'Undo', 10000 + ); + }); + + it('notifies faild files deletion', () => { + spyOn(nodeApiService, 'deleteNode').and.returnValue(Observable.throw(null)); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.ERRORS.NODE_DELETION_PLURAL', '', 10000 + ); + }); + + it('notifies partial deletion when only one file is successful', () => { + spyOn(nodeApiService, 'deleteNode').and.callFake((id) => { + if (id === '1') { + return Observable.throw(null); + } else { + return Observable.of(null); + } + }); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.NODE_DELETION.PARTIAL_SINGULAR', 'Undo', 10000 + ); + }); + + it('notifies partial deletion when some files are successful', () => { + spyOn(nodeApiService, 'deleteNode').and.callFake((id) => { + if (id === '1') { + return Observable.throw(null); + } + + if (id === '2') { + return Observable.of(null); + } + + if (id === '3') { + return Observable.of(null); + } + }); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } }, + { entry: { id: '3', name: 'name3' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.NODE_DELETION.PARTIAL_PLURAL', 'Undo', 10000 + ); + }); + }); + + describe('Restore action', () => { + beforeEach(() => { + spyOn(nodeApiService, 'deleteNode').and.returnValue(Observable.of(null)); + + spySnackBar = spyOn(notificationService, 'openSnackMessageAction').and.returnValue({ + onAction: () => Observable.of({}) + }); + }); + + it('notifies faild file on on restore', () => { + spyOn(nodeApiService, 'restoreNode').and.returnValue(Observable.throw(null)); + + component.selection = [ + { entry: { id: '1', name: 'name1' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(spySnackBar.calls.mostRecent().args) + .toEqual((['APP.MESSAGES.ERRORS.NODE_RESTORE', '', 3000])); + }); + + it('notifies faild files on on restore', () => { + spyOn(nodeApiService, 'restoreNode').and.returnValue(Observable.throw(null)); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(spySnackBar.calls.mostRecent().args) + .toEqual((['APP.MESSAGES.ERRORS.NODE_RESTORE_PLURAL', '', 3000])); + }); + + it('signals files restored', () => { + spyOn(contentService.restoreNode, 'next'); + spyOn(nodeApiService, 'restoreNode').and.callFake((id) => { + if (id === '1') { + return Observable.of(null); + } else { + return Observable.throw(null); + } + }); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(contentService.restoreNode.next).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/common/directives/node-delete.directive.ts b/src/app/common/directives/node-delete.directive.ts new file mode 100644 index 000000000..95502af98 --- /dev/null +++ b/src/app/common/directives/node-delete.directive.ts @@ -0,0 +1,237 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Directive, HostListener, Input } from '@angular/core'; + +import { TranslationService, NodesApiService, NotificationService } from 'ng2-alfresco-core'; +import { MinimalNodeEntity } from 'alfresco-js-api'; +import { Observable } from 'rxjs/Rx'; + +import { ContentManagementService } from '../services/content-management.service'; + +@Directive({ + selector: '[app-delete-node]' +}) +export class NodeDeleteDirective { + static RESTORE_MESSAGE_DURATION: number = 3000; + static DELETE_MESSAGE_DURATION: number = 10000; + + @Input('app-delete-node') + selection: MinimalNodeEntity[]; + + @HostListener('click') + onClick() { + this.deleteSelected(); + } + + constructor( + private nodesApi: NodesApiService, + private notification: NotificationService, + private content: ContentManagementService, + private translation: TranslationService + ) {} + + private deleteSelected(): void { + const batch = []; + + this.selection.forEach((node) => { + batch.push(this.performAction('delete', node.entry)); + }); + + Observable.forkJoin(...batch) + .subscribe( + (data) => { + const processedData = this.processStatus(data); + + this.getDeleteMesssage(processedData) + .subscribe((message) => { + const withUndo = processedData.someSucceeded ? 'Undo' : ''; + + this.notification.openSnackMessageAction(message, withUndo, NodeDeleteDirective.DELETE_MESSAGE_DURATION) + .onAction() + .subscribe(() => this.restore(processedData.success)); + + if (processedData.someSucceeded) { + this.content.deleteNode.next(null); + } + }); + } + ); + } + + private restore(items): void { + const batch = []; + + items.forEach((item) => { + batch.push(this.performAction('restore', item)); + }); + + Observable.forkJoin(...batch) + .subscribe( + (data) => { + const processedData = this.processStatus(data); + + if (processedData.failed.length) { + this.getRestoreMessage(processedData) + .subscribe((message) => { + this.notification.openSnackMessageAction( + message, '' , NodeDeleteDirective.RESTORE_MESSAGE_DURATION + ); + }); + } + + if (processedData.someSucceeded) { + this.content.restoreNode.next(null); + } + } + ); + } + + private performAction(action: string, item: any): Observable { + const { name } = item; + // Check if there's nodeId for Shared Files + const id = item.nodeId || item.id; + + let performedAction: any = null; + + if (action === 'delete') { + performedAction = this.nodesApi.deleteNode(id); + } else { + performedAction = this.nodesApi.restoreNode(id); + } + + return performedAction + .map(() => { + return { + id, + name, + status: 1 + }; + }) + .catch((error: any) => { + return Observable.of({ + id, + name, + status: 0 + }); + }); + } + + private processStatus(data): any { + const deleteStatus = { + success: [], + failed: [], + get someFailed() { + return !!(this.failed.length); + }, + get someSucceeded() { + return !!(this.success.length); + }, + get oneFailed() { + return this.failed.length === 1; + }, + get oneSucceeded() { + return this.success.length === 1; + }, + get allSucceeded() { + return this.someSucceeded && !this.someFailed; + }, + get allFailed() { + return this.someFailed && !this.someSucceeded; + } + }; + + return data.reduce( + (acc, next) => { + if (next.status === 1) { + acc.success.push(next); + } else { + acc.failed.push(next); + } + + return acc; + }, + deleteStatus + ); + } + + private getRestoreMessage(status): Observable { + if (status.someFailed && !status.oneFailed) { + return this.translation.get( + 'APP.MESSAGES.ERRORS.NODE_RESTORE_PLURAL', + { number: status.failed.length } + ); + } + + if (status.oneFailed) { + return this.translation.get( + 'APP.MESSAGES.ERRORS.NODE_RESTORE', + { name: status.failed[0].name } + ); + } + } + + private getDeleteMesssage(status): Observable { + if (status.allFailed && !status.oneFailed) { + return this.translation.get( + 'APP.MESSAGES.ERRORS.NODE_DELETION_PLURAL', + { number: status.failed.length } + ); + } + + if (status.allSucceeded && !status.oneSucceeded) { + return this.translation.get( + 'APP.MESSAGES.INFO.NODE_DELETION.PLURAL', + { number: status.success.length } + ); + } + + if (status.someFailed && status.someSucceeded && !status.oneSucceeded) { + return this.translation.get( + 'APP.MESSAGES.INFO.NODE_DELETION.PARTIAL_PLURAL', + { + success: status.success.length, + failed: status.failed.length + } + ); + } + + if (status.someFailed && status.oneSucceeded) { + return this.translation.get( + 'APP.MESSAGES.INFO.NODE_DELETION.PARTIAL_SINGULAR', + { + success: status.success.length, + failed: status.failed.length + } + ); + } + + if (status.oneFailed && !status.someSucceeded) { + return this.translation.get( + 'APP.MESSAGES.ERRORS.NODE_DELETION', + { name: status.failed[0].name } + ); + } + + if (status.oneSucceeded && !status.someFailed) { + return this.translation.get( + 'APP.MESSAGES.INFO.NODE_DELETION.SINGULAR', + { name: status.success[0].name } + ); + } + } +} diff --git a/src/app/common/directives/node-download.directive.spec.ts b/src/app/common/directives/node-download.directive.spec.ts new file mode 100644 index 000000000..a239c2cb7 --- /dev/null +++ b/src/app/common/directives/node-download.directive.spec.ts @@ -0,0 +1,142 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { CoreModule, AlfrescoApiService } from 'ng2-alfresco-core'; +import { MdDialog } from '@angular/material'; +import { Component, DebugElement } from '@angular/core'; +import { DownloadFileDirective } from './node-download.directive'; + +@Component({ + template: '
' +}) +class TestComponent { + selection; +} + +describe('DownloadFileDirective', () => { + let component: TestComponent; + let fixture: ComponentFixture; + let element: DebugElement; + let dialog: MdDialog; + let apiService: AlfrescoApiService; + let contentService; + let spySnackBar; + let dialogSpy; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CoreModule + ], + declarations: [ + TestComponent, + DownloadFileDirective + ], + providers: [ + AlfrescoApiService + ] + }); + + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + element = fixture.debugElement.query(By.directive(DownloadFileDirective)); + dialog = TestBed.get(MdDialog); + apiService = TestBed.get(AlfrescoApiService); + contentService = apiService.getInstance().content; + dialogSpy = spyOn(dialog, 'open'); + }); + + it('should not download node when selection is empty', () => { + spyOn(apiService, 'getInstance'); + component.selection = []; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(apiService.getInstance).not.toHaveBeenCalled(); + }); + + it('should not download zip when selection has no nodes', () => { + component.selection = []; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(dialogSpy).not.toHaveBeenCalled(); + }); + + it('should download selected node as file', () => { + spyOn(contentService, 'getContentUrl'); + const node = { entry: { id: 'node-id', isFile: true } }; + component.selection = [node]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(contentService.getContentUrl).toHaveBeenCalledWith(node.entry.id, true); + }); + + it('should download selected files nodes as zip', () => { + const node1 = { entry: { id: 'node-1' } }; + const node2 = { entry: { id: 'node-2' } }; + component.selection = [node1, node2]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(dialogSpy.calls.argsFor(0)[1].data).toEqual({ nodeIds: [ 'node-1', 'node-2' ] }); + }); + + it('should download selected folder node as zip', () => { + const node = { entry: { isFolder: true, id: 'node-id' } }; + component.selection = [node]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(dialogSpy.calls.argsFor(0)[1].data).toEqual({ nodeIds: [ 'node-id' ] }); + }); + + it('should create link element to download file node', () => { + const dummyLinkElement = { + download: null, + href: null, + click: () => null, + style: { + display: null + } + }; + + const node = { entry: { name: 'dummy', isFile: true, id: 'node-id' } }; + + spyOn(contentService, 'getContentUrl').and.returnValue('somewhere-over-the-rainbow'); + spyOn(document, 'createElement').and.returnValue(dummyLinkElement); + spyOn(document.body, 'appendChild').and.stub(); + spyOn(document.body, 'removeChild').and.stub(); + + component.selection = [node]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(document.createElement).toHaveBeenCalled(); + expect(dummyLinkElement.download).toBe('dummy'); + expect(dummyLinkElement.href).toContain('somewhere-over-the-rainbow'); + }); +}); diff --git a/src/app/common/directives/node-download.directive.ts b/src/app/common/directives/node-download.directive.ts new file mode 100644 index 000000000..83c55a80e --- /dev/null +++ b/src/app/common/directives/node-download.directive.ts @@ -0,0 +1,110 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Directive, Input, HostListener } from '@angular/core'; +import { MdDialog } from '@angular/material'; +import { MinimalNodeEntity } from 'alfresco-js-api'; +import { AlfrescoApiService, DownloadZipDialogComponent } from 'ng2-alfresco-core'; + +@Directive({ + selector: '[app-download-node]' +}) +export class DownloadFileDirective { + @Input('app-download-node') + nodes: MinimalNodeEntity[]; + + @HostListener('click') + onClick() { + this.downloadNodes(this.nodes); + } + + constructor( + private apiService: AlfrescoApiService, + private dialog: MdDialog + ) {} + + private downloadNodes(selection: Array) { + if (!selection || selection.length === 0) { + return; + } + + if (selection.length === 1) { + this.downloadNode(selection[0]); + } else { + this.downloadZip(selection); + } + } + + private downloadNode(node: MinimalNodeEntity) { + if (node && node.entry) { + const entry = node.entry; + + if (entry.isFile) { + this.downloadFile(node); + } + + if (entry.isFolder) { + this.downloadZip([node]); + } + + // Check if there's nodeId for Shared Files + if (!entry.isFile && !entry.isFolder && (entry).nodeId) { + this.downloadFile(node); + } + } + } + + private downloadFile(node: MinimalNodeEntity) { + if (node && node.entry) { + const contentApi = this.apiService.getInstance().content; + + const url = contentApi.getContentUrl(node.entry.id, true); + const fileName = node.entry.name; + + this.download(url, fileName); + } + } + + private downloadZip(selection: Array) { + if (selection && selection.length > 0) { + // nodeId for Shared node + const nodeIds = selection.map((node: any) => (node.entry.nodeId || node.entry.id)); + + this.dialog.open(DownloadZipDialogComponent, { + width: '600px', + disableClose: true, + data: { + nodeIds + } + }); + } + } + + private download(url: string, fileName: string) { + if (url && fileName) { + const link = document.createElement('a'); + + link.style.display = 'none'; + link.download = fileName; + link.href = url; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + } +} diff --git a/src/app/common/directives/node-favorite.directive.spec.ts b/src/app/common/directives/node-favorite.directive.spec.ts new file mode 100644 index 000000000..325e3e2ea --- /dev/null +++ b/src/app/common/directives/node-favorite.directive.spec.ts @@ -0,0 +1,332 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { CoreModule, TranslationService, NodesApiService } from 'ng2-alfresco-core'; +import { Component, DebugElement } from '@angular/core'; +import { Observable } from 'rxjs/Rx'; + +import { AlfrescoApiService } from 'ng2-alfresco-core'; +import { ContentManagementService } from '../services/content-management.service'; +import { NodeFavoriteDirective } from './node-favorite.directive'; + +@Component({ + template: '
' +}) +class TestComponent { + selection; +} + +describe('NodeFavoriteDirective', () => { + let component: TestComponent; + let fixture: ComponentFixture; + let element: DebugElement; + let directiveInstance; + let apiService; + let contentService; + let favoritesApi; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CoreModule + ], + declarations: [ + TestComponent, + NodeFavoriteDirective + ], + providers: [ + ContentManagementService, + AlfrescoApiService + ] + }); + + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + element = fixture.debugElement.query(By.directive(NodeFavoriteDirective)); + directiveInstance = element.injector.get(NodeFavoriteDirective); + + contentService = TestBed.get(ContentManagementService); + apiService = TestBed.get(AlfrescoApiService); + favoritesApi = apiService.getInstance().core.favoritesApi; + }); + + describe('selection input change event', () => { + it('does not call markFavoritesNodes() if input list is empty', () => { + spyOn(directiveInstance, 'markFavoritesNodes'); + + component.selection = []; + + fixture.detectChanges(); + + expect(directiveInstance.markFavoritesNodes).not.toHaveBeenCalledWith(); + }); + + it('calls markFavoritesNodes() on input change', () => { + spyOn(directiveInstance, 'markFavoritesNodes'); + + component.selection = [{ entry: { id: '1', name: 'name1' } }]; + + fixture.detectChanges(); + + expect(directiveInstance.markFavoritesNodes).toHaveBeenCalledWith(component.selection); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '1', name: 'name1' } } + ]; + + fixture.detectChanges(); + + expect(directiveInstance.markFavoritesNodes).toHaveBeenCalledWith(component.selection); + }); + }); + + describe('markFavoritesNodes()', () => { + let favoritesApiSpy; + + beforeEach(() => { + favoritesApiSpy = spyOn(favoritesApi, 'getFavorite'); + }); + + it('check each selected node if it is a favorite', fakeAsync(() => { + favoritesApiSpy.and.returnValue(Promise.resolve()); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } } + ]; + + fixture.detectChanges(); + tick(); + + expect(favoritesApiSpy.calls.count()).toBe(2); + })); + + it('it does not check processed node when another is unselected', fakeAsync(() => { + favoritesApiSpy.and.returnValue(Promise.resolve()); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } } + ]; + + fixture.detectChanges(); + tick(); + + expect(directiveInstance.favorites.length).toBe(2); + expect(favoritesApiSpy.calls.count()).toBe(2); + + favoritesApiSpy.calls.reset(); + + component.selection = [ + { entry: { id: '2', name: 'name2' } } + ]; + + fixture.detectChanges(); + tick(); + + expect(directiveInstance.favorites.length).toBe(1); + expect(favoritesApiSpy).not.toHaveBeenCalled(); + })); + + it('it does not check processed nodes when another is selected', fakeAsync(() => { + favoritesApiSpy.and.returnValue(Promise.resolve()); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } } + ]; + + fixture.detectChanges(); + tick(); + + expect(directiveInstance.favorites.length).toBe(2); + expect(favoritesApiSpy.calls.count()).toBe(2); + + favoritesApiSpy.calls.reset(); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } }, + { entry: { id: '3', name: 'name3' } } + ]; + + fixture.detectChanges(); + tick(); + + expect(directiveInstance.favorites.length).toBe(3); + expect(favoritesApiSpy.calls.count()).toBe(1); + })); + }); + + describe('toggleFavorite()', () => { + let removeFavoriteSpy; + let addFavoriteSpy; + + beforeEach(() => { + removeFavoriteSpy = spyOn(favoritesApi, 'removeFavoriteSite'); + addFavoriteSpy = spyOn(favoritesApi, 'addFavorite'); + }); + + it('does not perform action if favorites collection is empty', () => { + component.selection = []; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(removeFavoriteSpy).not.toHaveBeenCalled(); + expect(addFavoriteSpy).not.toHaveBeenCalled(); + }); + + it('calls addFavorite() if none is a favorite', fakeAsync(() => { + addFavoriteSpy.and.returnValue(Promise.resolve()); + + directiveInstance.favorites = [ + { entry: { id: '1', name: 'name1', isFavorite: false } }, + { entry: { id: '2', name: 'name2', isFavorite: false } } + ]; + + element.triggerEventHandler('click', null); + tick(); + + expect(addFavoriteSpy.calls.argsFor(0)[1].length).toBe(2); + })); + + it('calls addFavorite() on the node that is not a favorite in selection', fakeAsync(() => { + addFavoriteSpy.and.returnValue(Promise.resolve()); + + directiveInstance.favorites = [ + { entry: { id: '1', name: 'name1', isFile: true, isFolder: false, isFavorite: false } }, + { entry: { id: '2', name: 'name2', isFile: true, isFolder: false, isFavorite: true } } + ]; + + element.triggerEventHandler('click', null); + tick(); + + const callArgs = addFavoriteSpy.calls.argsFor(0)[1]; + const callParameter = callArgs[0]; + + expect(callArgs.length).toBe(1); + expect(callParameter.target.file.guid).toBe('1'); + })); + + it('calls removeFavoriteSite() if all are favorites', fakeAsync(() => { + addFavoriteSpy.and.returnValue(Promise.resolve()); + + directiveInstance.favorites = [ + { entry: { id: '1', name: 'name1', isFavorite: true } }, + { entry: { id: '2', name: 'name2', isFavorite: true } } + ]; + + element.triggerEventHandler('click', null); + tick(); + + expect(removeFavoriteSpy.calls.count()).toBe(2); + })); + }); + + describe('getFavorite()', () => { + it('process node as favorite', fakeAsync(() => { + spyOn(favoritesApi, 'getFavorite').and.returnValue(Promise.resolve()); + + component.selection = [ + { entry: { id: '1', name: 'name1' } } + ]; + + fixture.detectChanges(); + tick(); + + expect(directiveInstance.favorites[0].entry.isFavorite).toBe(true); + })); + + it('process node as not a favorite', fakeAsync(() => { + spyOn(favoritesApi, 'getFavorite').and.returnValue(Promise.reject(null)); + + component.selection = [ + { entry: { id: '1', name: 'name1' } } + ]; + + fixture.detectChanges(); + tick(); + + expect(directiveInstance.favorites[0].entry.isFavorite).toBe(false); + })); + }); + + describe('reset()', () => { + beforeEach(() => { + spyOn(favoritesApi, 'removeFavoriteSite').and.returnValue(Promise.resolve()); + spyOn(favoritesApi, 'addFavorite').and.returnValue(Promise.resolve()); + }); + + it('reset favorite collection after addFavorite()', fakeAsync(() => { + directiveInstance.favorites = [ + { entry: { id: '1', name: 'name1', isFavorite: true } } + ]; + + element.triggerEventHandler('click', null); + tick(); + + expect(directiveInstance.favorites.length).toBe(0); + })); + + it('reset favorite collection after removeFavoriteSite()', fakeAsync(() => { + directiveInstance.favorites = [ + { entry: { id: '1', name: 'name1', isFavorite: false } } + ]; + + element.triggerEventHandler('click', null); + tick(); + + expect(directiveInstance.favorites.length).toBe(0); + })); + }); + + describe('hasFavorites()', () => { + it('returns false if favorites collection is empty', () => { + directiveInstance.favorites = []; + + const hasFavorites = directiveInstance.hasFavorites(); + + expect(hasFavorites).toBe(false); + }); + + it('returns false if some are not favorite', () => { + directiveInstance.favorites = [ + { entry: { id: '1', name: 'name1', isFavorite: true } }, + { entry: { id: '2', name: 'name2', isFavorite: false } } + ]; + + const hasFavorites = directiveInstance.hasFavorites(); + + expect(hasFavorites).toBe(false); + }); + + it('returns true if all are favorite', () => { + directiveInstance.favorites = [ + { entry: { id: '1', name: 'name1', isFavorite: true } }, + { entry: { id: '2', name: 'name2', isFavorite: true } } + ]; + + const hasFavorites = directiveInstance.hasFavorites(); + + expect(hasFavorites).toBe(true); + }); + }); +}); diff --git a/src/app/common/directives/node-favorite.directive.ts b/src/app/common/directives/node-favorite.directive.ts new file mode 100644 index 000000000..27460d1b1 --- /dev/null +++ b/src/app/common/directives/node-favorite.directive.ts @@ -0,0 +1,181 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Directive, HostListener, Input, OnChanges } from '@angular/core'; +import { AlfrescoApiService } from 'ng2-alfresco-core'; + +import { MinimalNodeEntity, FavoriteBody } from 'alfresco-js-api'; +import { Observable } from 'rxjs/Rx'; + +import { ContentManagementService } from '../services/content-management.service'; + +@Directive({ + selector: '[app-favorite-node]', + exportAs: 'favorite' +}) +export class NodeFavoriteDirective implements OnChanges { + private favorites: any[] = []; + + @Input('app-favorite-node') + selection: any[]; + + @HostListener('click') + onClick() { + this.toggleFavorite(); + } + + constructor( + public content: ContentManagementService, + private alfrescoApiService: AlfrescoApiService + ) {} + + ngOnChanges(changes) { + if (!changes.selection.currentValue.length) { + return; + } + + this.markFavoritesNodes(changes.selection.currentValue); + } + + toggleFavorite() { + if (!this.favorites.length) { + return; + } + + const every = this.favorites.every((selected) => selected.entry.isFavorite); + + if (every) { + const batch = this.favorites.map((selected) => { + // shared files have nodeId + const id = selected.entry.nodeId || selected.entry.id; + + return Observable.of(this.alfrescoApiService.getInstance().core.favoritesApi.removeFavoriteSite('-me-', id)); + }); + + Observable.forkJoin(batch) + .subscribe(() => { + this.content.toggleFavorite.next(); + this.reset(); + }); + } + + if (!every) { + const notFavorite = this.favorites.filter((node) => !node.entry.isFavorite); + const body: FavoriteBody[] = notFavorite.map((node) => this.createFavoriteBody(node)); + + Observable.from(this.alfrescoApiService.getInstance().core.favoritesApi.addFavorite('-me-', body)) + .subscribe(() => { + this.content.toggleFavorite.next(); + this.reset(); + }); + } + } + + markFavoritesNodes(selection) { + if (selection.length < this.favorites.length) { + const newFavorites = this.reduce(this.favorites, selection); + this.favorites = newFavorites; + } + + const result = this.diff(selection, this.favorites); + const batch = this.getProcessBatch(result); + + Observable.forkJoin(batch).subscribe((data) => this.favorites.push(...data)); + } + + hasFavorites(): boolean { + if (this.favorites && !this.favorites.length) { + return false; + } + + return this.favorites.every((selected) => selected.entry.isFavorite); + } + + private reset() { + this.favorites = []; + } + + private getProcessBatch(selection): any[] { + return selection.map((selected) => this.getFavorite(selected)); + } + + private getFavorite(selected): Observable { + const { name, isFile, isFolder } = selected.entry; + // shared files have nodeId + const id = selected.entry.nodeId || selected.entry.id; + + const promise = this.alfrescoApiService.getInstance() + .core.favoritesApi.getFavorite('-me-', id); + + return Observable.from(promise) + .map(() => ({ + entry: { + id, + isFolder, + isFile, + name, + isFavorite: true + } + })) + .catch(() => { + return Observable.of({ + entry: { + id, + isFolder, + isFile, + name, + isFavorite: false + } + }); + }); + } + + private createFavoriteBody(node): FavoriteBody { + const type = this.getNodeType(node); + // shared files have nodeId + const id = node.entry.nodeId || node.entry.id; + + return { + target: { + [type]: { + guid: id + } + } + }; + } + + private getNodeType(node): string { + // shared could only be files + if (!node.entry.isFile && !node.entry.isFolder) { + return 'file'; + } + + return node.entry.isFile ? 'file' : 'folder'; + } + + private diff(list, patch): any[] { + const ids = patch.map(item => item.entry.id); + + return list.filter(item => ids.includes(item.entry.id) ? null : item); + } + + private reduce(patch, comparator): any[] { + const ids = comparator.map(item => item.entry.id); + + return patch.filter(item => ids.includes(item.entry.id) ? item : null); + } +} diff --git a/src/app/common/directives/node-move.directive.spec.ts b/src/app/common/directives/node-move.directive.spec.ts new file mode 100644 index 000000000..78f3856cd --- /dev/null +++ b/src/app/common/directives/node-move.directive.spec.ts @@ -0,0 +1,299 @@ +import { Component, DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { Observable } from 'rxjs/Rx'; + +import { CoreModule, TranslationService, NodesApiService, NotificationService } from 'ng2-alfresco-core'; +import { DocumentListModule } from 'ng2-alfresco-documentlist'; + +import { NodeActionsService } from '../services/node-actions.service'; +import { ContentManagementService } from '../services/content-management.service'; +import { NodeMoveDirective } from './node-move.directive'; + +@Component({ + template: '
' +}) +class TestComponent { + selection; +} + +describe('NodeMoveDirective', () => { + let fixture: ComponentFixture; + let component: TestComponent; + let element: DebugElement; + let notificationService: NotificationService; + let nodesApiService: NodesApiService; + let service: NodeActionsService; + let translationService: TranslationService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CoreModule, + DocumentListModule + ], + declarations: [ + TestComponent, + NodeMoveDirective + ], + providers: [ + ContentManagementService, + NodeActionsService + ] + }); + + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + element = fixture.debugElement.query(By.directive(NodeMoveDirective)); + notificationService = TestBed.get(NotificationService); + nodesApiService = TestBed.get(NodesApiService); + service = TestBed.get(NodeActionsService); + translationService = TestBed.get(TranslationService); + }); + + beforeEach(() => { + spyOn(translationService, 'get').and.callFake((keysArray) => { + const processedKeys = {}; + keysArray.forEach((key) => { + processedKeys[key] = key; + }); + return Observable.of(processedKeys); + }); + }); + + describe('Move node action', () => { + beforeEach(() => { + spyOn(notificationService, 'openSnackMessageAction').and.callThrough(); + }); + + it('notifies successful move of a node', () => { + const node = [ { entry: { id: 'node-to-move-id', name: 'name' } } ]; + const moveResponse = { + succeeded: node, + failed: [], + partiallySucceeded: [] + }; + + spyOn(service, 'moveNodes').and.returnValue(Observable.of('OPERATION.SUCCES.CONTENT.MOVE')); + spyOn(service, 'processResponse').and.returnValue(moveResponse); + + component.selection = node; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentMoved.next(moveResponse); + + expect(service.moveNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.NODE_MOVE.SINGULAR', 'Undo', 10000 + ); + }); + + it('notifies successful move of multiple nodes', () => { + const nodes = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } }]; + const moveResponse = { + succeeded: nodes, + failed: [], + partiallySucceeded: [] + }; + + spyOn(service, 'moveNodes').and.returnValue(Observable.of('OPERATION.SUCCES.CONTENT.MOVE')); + spyOn(service, 'processResponse').and.returnValue(moveResponse); + + component.selection = nodes; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentMoved.next(moveResponse); + + expect(service.moveNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.NODE_MOVE.PLURAL', 'Undo', 10000 + ); + }); + + it('notifies partial move of a node', () => { + const node = [ { entry: { id: '1', name: 'name' } } ]; + const moveResponse = { + succeeded: [], + failed: [], + partiallySucceeded: node + }; + + spyOn(service, 'moveNodes').and.returnValue(Observable.of('OPERATION.SUCCES.CONTENT.MOVE')); + spyOn(service, 'processResponse').and.returnValue(moveResponse); + + component.selection = node; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentMoved.next(moveResponse); + + expect(service.moveNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.NODE_MOVE.PARTIAL.SINGULAR', 'Undo', 10000 + ); + }); + + it('notifies partial move of multiple nodes', () => { + const nodes = [ + { entry: { id: '1', name: 'name' } }, + { entry: { id: '2', name: 'name2' } } ]; + const moveResponse = { + succeeded: [], + failed: [], + partiallySucceeded: nodes + }; + + spyOn(service, 'moveNodes').and.returnValue(Observable.of('OPERATION.SUCCES.CONTENT.MOVE')); + spyOn(service, 'processResponse').and.returnValue(moveResponse); + + component.selection = nodes; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentMoved.next(moveResponse); + + expect(service.moveNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.NODE_MOVE.PARTIAL.PLURAL', 'Undo', 10000 + ); + }); + + it('notifies successful move and the number of nodes that could not be moved', () => { + const nodes = [ { entry: { id: '1', name: 'name' } }, + { entry: { id: '2', name: 'name2' } } ]; + const moveResponse = { + succeeded: [ nodes[0] ], + failed: [ nodes[1] ], + partiallySucceeded: [] + }; + + spyOn(service, 'moveNodes').and.returnValue(Observable.of('OPERATION.SUCCES.CONTENT.MOVE')); + spyOn(service, 'processResponse').and.returnValue(moveResponse); + + component.selection = nodes; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentMoved.next(moveResponse); + + expect(service.moveNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.NODE_MOVE.SINGULAR APP.MESSAGES.INFO.NODE_MOVE.PARTIAL.FAIL', 'Undo', 10000 + ); + }); + + it('notifies successful move and the number of partially moved ones', () => { + const nodes = [ { entry: { id: '1', name: 'name' } }, + { entry: { id: '2', name: 'name2' } } ]; + const moveResponse = { + succeeded: [ nodes[0] ], + failed: [], + partiallySucceeded: [ nodes[1] ] + }; + + spyOn(service, 'moveNodes').and.returnValue(Observable.of('OPERATION.SUCCES.CONTENT.MOVE')); + spyOn(service, 'processResponse').and.returnValue(moveResponse); + + component.selection = nodes; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentMoved.next(moveResponse); + + expect(service.moveNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.NODE_MOVE.SINGULAR APP.MESSAGES.INFO.NODE_MOVE.PARTIAL.SINGULAR', 'Undo', 10000 + ); + }); + + it('notifies error if success message was not emitted', () => { + const node = { entry: { id: 'node-to-move-id', name: 'name' } }; + const moveResponse = { + succeeded: [], + failed: [], + partiallySucceeded: [] + }; + + spyOn(service, 'moveNodes').and.returnValue(Observable.of('')); + + component.selection = [ node ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentMoved.next(moveResponse); + + expect(service.moveNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith('APP.MESSAGES.ERRORS.GENERIC', '', 3000); + }); + + it('notifies permission error on move of node', () => { + spyOn(service, 'moveNodes').and.returnValue(Observable.throw(new Error(JSON.stringify({error: {statusCode: 403}})))); + + component.selection = [{ entry: { id: '1', name: 'name' } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(service.moveNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.ERRORS.PERMISSION', '', 3000 + ); + }); + + it('notifies generic error message on all errors, but 403', () => { + spyOn(service, 'moveNodes').and.returnValue(Observable.throw(new Error(JSON.stringify({error: {statusCode: 404}})))); + + component.selection = [{ entry: { id: '1', name: 'name' } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(service.moveNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.ERRORS.GENERIC', '', 3000 + ); + }); + + it('notifies conflict error message on 409', () => { + spyOn(service, 'moveNodes').and.returnValue(Observable.throw(new Error(JSON.stringify({error: {statusCode: 409}})))); + + component.selection = [{ entry: { id: '1', name: 'name' } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(service.moveNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.ERRORS.NODE_MOVE', '', 3000 + ); + }); + + it('notifies error if move response has only failed items', () => { + const node = [ { entry: { id: '1', name: 'name' } } ]; + const moveResponse = { + succeeded: [], + failed: [ {} ], + partiallySucceeded: [] + }; + + spyOn(service, 'moveNodes').and.returnValue(Observable.of('OPERATION.SUCCES.CONTENT.MOVE')); + spyOn(service, 'processResponse').and.returnValue(moveResponse); + + component.selection = node; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + service.contentMoved.next(moveResponse); + + expect(service.moveNodes).toHaveBeenCalled(); + expect(notificationService.openSnackMessageAction).toHaveBeenCalledWith( + 'APP.MESSAGES.ERRORS.GENERIC', '', 3000 + ); + }); + }); +}); diff --git a/src/app/common/directives/node-move.directive.ts b/src/app/common/directives/node-move.directive.ts new file mode 100644 index 000000000..c329b1fb3 --- /dev/null +++ b/src/app/common/directives/node-move.directive.ts @@ -0,0 +1,210 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Directive, HostListener, Input } from '@angular/core'; + +import { TranslationService, NodesApiService, NotificationService } from 'ng2-alfresco-core'; +import { MinimalNodeEntity } from 'alfresco-js-api'; + +import { ContentManagementService } from '../services/content-management.service'; +import { NodeActionsService } from '../services/node-actions.service'; +import { Observable } from 'rxjs/Rx'; + +@Directive({ + selector: '[app-move-node]' +}) + +export class NodeMoveDirective { + @Input('app-move-node') + selection: MinimalNodeEntity[]; + + @HostListener('click') + onClick() { + this.moveSelected(); + } + + constructor( + private content: ContentManagementService, + private notification: NotificationService, + private nodeActionsService: NodeActionsService, + private nodesApi: NodesApiService, + private translation: TranslationService + ) {} + + moveSelected() { + const permissionForMove: string = 'delete'; + + Observable.zip( + this.nodeActionsService.moveNodes(this.selection, permissionForMove), + this.nodeActionsService.contentMoved + ).subscribe( + (result) => { + const [ operationResult, moveResponse ] = result; + this.toastMessage(operationResult, moveResponse); + + this.content.moveNode.next(null); + }, + (error) => { + this.toastMessage(error); + } + ); + } + + private toastMessage(info: any, moveResponse?: any) { + const succeeded = (moveResponse && moveResponse['succeeded']) ? moveResponse['succeeded'].length : 0; + const partiallySucceeded = (moveResponse && moveResponse['partiallySucceeded']) ? moveResponse['partiallySucceeded'].length : 0; + const failures = (moveResponse && moveResponse['failed']) ? moveResponse['failed'].length : 0; + + let successMessage = ''; + let partialSuccessMessage = ''; + let failedMessage = ''; + let errorMessage = ''; + + if (typeof info === 'string') { + + // in case of success + if (info.toLowerCase().indexOf('succes') !== -1) { + let i18nMessageString = 'APP.MESSAGES.INFO.NODE_MOVE.'; + let i18MessageSuffix = ''; + + if (succeeded) { + i18MessageSuffix = ( succeeded === 1 ) ? 'SINGULAR' : 'PLURAL'; + successMessage = `${i18nMessageString}${i18MessageSuffix}`; + } + + if (partiallySucceeded) { + i18MessageSuffix = ( partiallySucceeded === 1 ) ? 'PARTIAL.SINGULAR' : 'PARTIAL.PLURAL'; + partialSuccessMessage = `${i18nMessageString}${i18MessageSuffix}`; + } + + if (failures) { + // if moving failed for ALL nodes, emit error + if (failures === this.selection.length) { + const errors = this.nodeActionsService.flatten(moveResponse['failed']); + errorMessage = this.getErrorMessage(errors[0]); + + } else { + i18MessageSuffix = 'PARTIAL.FAIL'; + failedMessage = `${i18nMessageString}${i18MessageSuffix}`; + } + } + } else { + errorMessage = 'APP.MESSAGES.ERRORS.GENERIC'; + } + + } else { + errorMessage = this.getErrorMessage(info); + } + + const undo = (succeeded + partiallySucceeded > 0) ? 'Undo' : ''; + const withUndo = errorMessage ? '' : '_WITH_UNDO'; + failedMessage = errorMessage ? errorMessage : failedMessage; + + const beforePartialSuccessMessage = (successMessage && partialSuccessMessage) ? ' ' : ''; + const beforeFailedMessage = ((successMessage || partialSuccessMessage) && failedMessage) ? ' ' : ''; + + const initialParentId = this.nodeActionsService.getFirstParentId(this.selection); + + this.translation.get( + [successMessage, partialSuccessMessage, failedMessage], + { success: succeeded, failed: failures, partially: partiallySucceeded}).subscribe(messages => { + + this.notification.openSnackMessageAction( + messages[successMessage] + + beforePartialSuccessMessage + messages[partialSuccessMessage] + + beforeFailedMessage + messages[failedMessage], + undo, + NodeActionsService[`SNACK_MESSAGE_DURATION${withUndo}`] + ) + .onAction() + .subscribe(() => this.revertMoving(moveResponse, initialParentId)); + }); + } + + getErrorMessage(errorObject): string { + let i18nMessageString = 'APP.MESSAGES.ERRORS.GENERIC'; + + try { + const { error: { statusCode } } = JSON.parse(errorObject.message); + + if (statusCode === 409) { + i18nMessageString = 'APP.MESSAGES.ERRORS.NODE_MOVE'; + + } else if (statusCode === 403) { + i18nMessageString = 'APP.MESSAGES.ERRORS.PERMISSION'; + } + + } catch (err) { /* Do nothing, keep the original message */ } + + return i18nMessageString; + } + + private revertMoving(moveResponse, selectionParentId) { + const movedNodes = (moveResponse && moveResponse['succeeded']) ? moveResponse['succeeded'] : []; + const partiallyMovedNodes = (moveResponse && moveResponse['partiallySucceeded']) ? moveResponse['partiallySucceeded'] : []; + + const restoreDeletedNodesBatch = this.nodeActionsService.moveDeletedEntries + .map((folderEntry) => { + return this.nodesApi.restoreNode(folderEntry.nodeId || folderEntry.id) + .catch((err) => Observable.of(err)); + }); + + Observable.zip(...restoreDeletedNodesBatch, Observable.of(null)) + .flatMap(() => { + + const nodesToBeMovedBack = [...partiallyMovedNodes, ...movedNodes]; + + const revertMoveBatch = this.nodeActionsService + .flatten(nodesToBeMovedBack) + .filter(node => node.entry || (node.itemMoved && node.itemMoved.entry)) + .map((node) => { + if (node.itemMoved) { + return this.nodeActionsService.moveNodeAction(node.itemMoved.entry, node.initialParentId); + } else { + return this.nodeActionsService.moveNodeAction(node.entry, selectionParentId); + } + }); + + return Observable.zip(...revertMoveBatch, Observable.of(null)); + }) + .subscribe( + () => { + this.content.moveNode.next(null); + }, + (error) => { + + let i18nMessageString = 'APP.MESSAGES.ERRORS.GENERIC'; + + let errorJson = null; + try { + errorJson = JSON.parse(error.message); + } catch (e) { // + } + + if (errorJson && errorJson.error && errorJson.error.statusCode === 403) { + i18nMessageString = 'APP.MESSAGES.ERRORS.PERMISSION'; + } + + this.translation.get(i18nMessageString).subscribe(message => { + this.notification.openSnackMessage( + message, NodeActionsService.SNACK_MESSAGE_DURATION); + }); + } + ); + } + +} diff --git a/src/app/common/directives/node-permanent-delete.directive.spec.ts b/src/app/common/directives/node-permanent-delete.directive.spec.ts new file mode 100644 index 000000000..9bab26f07 --- /dev/null +++ b/src/app/common/directives/node-permanent-delete.directive.spec.ts @@ -0,0 +1,339 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, DebugElement } from '@angular/core'; +import { TestBed, ComponentFixture, async, fakeAsync, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Observable } from 'rxjs/Rx'; +import { CoreModule, AlfrescoApiService, TranslationService, NotificationService } from 'ng2-alfresco-core'; + +import { NodePermanentDeleteDirective } from './node-permanent-delete.directive'; + +@Component({ + template: `
` +}) +class TestComponent { + selection = []; +} + +describe('NodePermanentDeleteDirective', () => { + let fixture: ComponentFixture; + let element: DebugElement; + let component: TestComponent; + let alfrescoService: AlfrescoApiService; + let translation: TranslationService; + let notificationService: NotificationService; + let nodesService; + let directiveInstance; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CoreModule + ], + declarations: [ + TestComponent, + NodePermanentDeleteDirective + ] + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + element = fixture.debugElement.query(By.directive(NodePermanentDeleteDirective)); + directiveInstance = element.injector.get(NodePermanentDeleteDirective); + + alfrescoService = TestBed.get(AlfrescoApiService); + nodesService = alfrescoService.getInstance().nodes; + translation = TestBed.get(TranslationService); + notificationService = TestBed.get(NotificationService); + }); + })); + + beforeEach(() => { + spyOn(translation, 'get').and.returnValue(Observable.of('message')); + spyOn(notificationService, 'openSnackMessage').and.returnValue({}); + }); + + it('does not purge nodes if no selection', () => { + spyOn(nodesService, 'purgeDeletedNode'); + + component.selection = []; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(nodesService.purgeDeletedNode).not.toHaveBeenCalled(); + }); + + it('call purge nodes if selection is not empty', fakeAsync(() => { + spyOn(nodesService, 'purgeDeletedNode').and.returnValue(Promise.resolve()); + + component.selection = [ { entry: { id: '1' } } ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(nodesService.purgeDeletedNode).toHaveBeenCalled(); + })); + + describe('notification', () => { + it('notifies on multiple fail and one success', fakeAsync(() => { + spyOn(nodesService, 'purgeDeletedNode').and.callFake((id) => { + if (id === '1') { + return Promise.resolve(); + } + + if (id === '2') { + return Promise.reject({}); + } + + if (id === '3') { + return Promise.reject({}); + } + }); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } }, + { entry: { id: '3', name: 'name3' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(notificationService.openSnackMessage).toHaveBeenCalled(); + expect(translation.get).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.TRASH.NODES_PURGE.PARTIAL_SINGULAR', + { name: 'name1', failed: 2 } + ); + })); + + it('notifies on multiple success and multiple fail', fakeAsync(() => { + spyOn(nodesService, 'purgeDeletedNode').and.callFake((id) => { + if (id === '1') { + return Promise.resolve(); + } + + if (id === '2') { + return Promise.reject({}); + } + + if (id === '3') { + return Promise.reject({}); + } + + if (id === '4') { + return Promise.resolve(); + } + }); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } }, + { entry: { id: '3', name: 'name3' } }, + { entry: { id: '4', name: 'name4' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(notificationService.openSnackMessage).toHaveBeenCalled(); + expect(translation.get).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.TRASH.NODES_PURGE.PARTIAL_PLURAL', + { number: 2, failed: 2 } + ); + })); + + it('notifies on one selected node success', fakeAsync(() => { + spyOn(nodesService, 'purgeDeletedNode').and.returnValue(Promise.resolve()); + + component.selection = [ + { entry: { id: '1', name: 'name1' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(notificationService.openSnackMessage).toHaveBeenCalled(); + expect(translation.get).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.TRASH.NODES_PURGE.SINGULAR', + { name: 'name1' } + ); + })); + + it('notifies on one selected node fail', fakeAsync(() => { + spyOn(nodesService, 'purgeDeletedNode').and.returnValue(Promise.reject({})); + + component.selection = [ + { entry: { id: '1', name: 'name1' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(notificationService.openSnackMessage).toHaveBeenCalled(); + expect(translation.get).toHaveBeenCalledWith( + 'APP.MESSAGES.ERRORS.TRASH.NODES_PURGE.SINGULAR', + { name: 'name1' } + ); + })); + + it('notifies on selected nodes success', fakeAsync(() => { + spyOn(nodesService, 'purgeDeletedNode').and.callFake((id) => { + if (id === '1') { + return Promise.resolve(); + } + + if (id === '2') { + return Promise.resolve(); + } + }); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(notificationService.openSnackMessage).toHaveBeenCalled(); + expect(translation.get).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.TRASH.NODES_PURGE.PLURAL', + { number: 2 } + ); + })); + + it('notifies on selected nodes fail', fakeAsync(() => { + spyOn(nodesService, 'purgeDeletedNode').and.callFake((id) => { + if (id === '1') { + return Promise.reject({}); + } + + if (id === '2') { + return Promise.reject({}); + } + }); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(notificationService.openSnackMessage).toHaveBeenCalled(); + expect(translation.get).toHaveBeenCalledWith( + 'APP.MESSAGES.ERRORS.TRASH.NODES_PURGE.PLURAL', + { number: 2 } + ); + })); + }); + + describe('refresh()', () => { + it('resets selection on success', fakeAsync(() => { + spyOn(nodesService, 'purgeDeletedNode').and.returnValue(Promise.resolve()); + + component.selection = [ + { entry: { id: '1', name: 'name1' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(directiveInstance.selection).toEqual([]); + })); + + it('resets selection on error', fakeAsync(() => { + spyOn(nodesService, 'purgeDeletedNode').and.returnValue(Promise.reject({})); + + component.selection = [ + { entry: { id: '1', name: 'name1' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(directiveInstance.selection).toEqual([]); + })); + + it('resets status', fakeAsync(() => { + const status = directiveInstance.processStatus([ + { status: 0 }, + { status: 1 } + ]); + + expect(status.fail.length).toBe(1); + expect(status.success.length).toBe(1); + + status.reset(); + + expect(status.fail.length).toBe(0); + expect(status.success.length).toBe(0); + })); + + it('dispatch event on partial success', fakeAsync(() => { + spyOn(element.nativeElement, 'dispatchEvent'); + spyOn(nodesService, 'purgeDeletedNode').and.callFake((id) => { + if (id === '1') { + return Promise.reject({}); + } + + if (id === '2') { + return Promise.resolve(); + } + }); + + component.selection = [ + { entry: { id: '1', name: 'name1' } }, + { entry: { id: '2', name: 'name2' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(element.nativeElement.dispatchEvent).toHaveBeenCalled(); + })); + + it('does not dispatch event on error', fakeAsync(() => { + spyOn(nodesService, 'purgeDeletedNode').and.returnValue(Promise.reject({})); + spyOn(element.nativeElement, 'dispatchEvent'); + + component.selection = [ + { entry: { id: '1', name: 'name1' } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(element.nativeElement.dispatchEvent).not.toHaveBeenCalled(); + })); + }); +}); diff --git a/src/app/common/directives/node-permanent-delete.directive.ts b/src/app/common/directives/node-permanent-delete.directive.ts new file mode 100644 index 000000000..8c54da68d --- /dev/null +++ b/src/app/common/directives/node-permanent-delete.directive.ts @@ -0,0 +1,194 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Directive, ElementRef, HostListener, Input } from '@angular/core'; +import { Observable } from 'rxjs/Rx'; + +import { TranslationService, AlfrescoApiService, NotificationService } from 'ng2-alfresco-core'; +import { MinimalNodeEntity, DeletedNodeEntry, PathInfoEntity } from 'alfresco-js-api'; + +@Directive({ + selector: '[app-permanent-delete-node]' +}) +export class NodePermanentDeleteDirective { + + @Input('app-permanent-delete-node') + selection: MinimalNodeEntity[]; + + @HostListener('click') + onClick() { + this.purge(); + } + + constructor( + private alfrescoApiService: AlfrescoApiService, + private translation: TranslationService, + private notification: NotificationService, + private el: ElementRef + ) {} + + private purge() { + if (!this.selection.length) { + return; + } + + const batch = this.getPurgedNodesBatch(this.selection); + + Observable.forkJoin(batch) + .subscribe( + (purgedNodes) => { + const status = this.processStatus(purgedNodes); + + this.purgeNotification(status); + + if (status.success.length) { + this.emitDone(); + } + + this.selection = []; + status.reset(); + } + ); + } + + private getPurgedNodesBatch(selection): Observable { + return selection.map((node: MinimalNodeEntity) => this.purgeDeletedNode(node)); + } + + private purgeDeletedNode(node): Observable { + const { id, name } = node.entry; + const promise = this.alfrescoApiService.getInstance().nodes.purgeDeletedNode(id); + + return Observable.from(promise) + .map(() => ({ + status: 1, + id, + name + })) + .catch((error) => { + return Observable.of({ + status: 0, + id, + name + }); + }); + } + + private purgeNotification(status): void { + this.getPurgeMessage(status) + .subscribe((message) => { + this.notification.openSnackMessage(message, 3000); + }); + } + + private getPurgeMessage(status): Observable { + if (status.oneSucceeded && status.someFailed && !status.oneFailed) { + return this.translation.get( + 'APP.MESSAGES.INFO.TRASH.NODES_PURGE.PARTIAL_SINGULAR', + { + name: status.success[0].name, + failed: status.fail.length + } + ); + } + + if (status.someSucceeded && !status.oneSucceeded && status.someFailed) { + return this.translation.get( + 'APP.MESSAGES.INFO.TRASH.NODES_PURGE.PARTIAL_PLURAL', + { + number: status.success.length, + failed: status.fail.length + } + ); + } + + if (status.oneSucceeded) { + return this.translation.get( + 'APP.MESSAGES.INFO.TRASH.NODES_PURGE.SINGULAR', + { name: status.success[0].name } + ); + } + + if (status.oneFailed) { + return this.translation.get( + 'APP.MESSAGES.ERRORS.TRASH.NODES_PURGE.SINGULAR', + { name: status.fail[0].name } + ); + } + + if (status.allSucceeded) { + return this.translation.get( + 'APP.MESSAGES.INFO.TRASH.NODES_PURGE.PLURAL', + { number: status.success.length } + ); + } + + if (status.allFailed) { + return this.translation.get( + 'APP.MESSAGES.ERRORS.TRASH.NODES_PURGE.PLURAL', + { number: status.fail.length } + ); + } + } + + private processStatus(data = []): any { + const status = { + fail: [], + success: [], + get someFailed() { + return !!(this.fail.length); + }, + get someSucceeded() { + return !!(this.success.length); + }, + get oneFailed() { + return this.fail.length === 1; + }, + get oneSucceeded() { + return this.success.length === 1; + }, + get allSucceeded() { + return this.someSucceeded && !this.someFailed; + }, + get allFailed() { + return this.someFailed && !this.someSucceeded; + }, + reset() { + this.fail = []; + this.success = []; + } + }; + + return data.reduce( + (acc, node) => { + if (node.status) { + acc.success.push(node); + } else { + acc.fail.push(node); + } + + return acc; + }, + status + ); + } + + private emitDone() { + const e = new CustomEvent('selection-node-deleted', { bubbles: true }); + this.el.nativeElement.dispatchEvent(e); + } +} diff --git a/src/app/common/directives/node-restore.directive.spec.ts b/src/app/common/directives/node-restore.directive.spec.ts new file mode 100644 index 000000000..3a282a53c --- /dev/null +++ b/src/app/common/directives/node-restore.directive.spec.ts @@ -0,0 +1,334 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, DebugElement } from '@angular/core'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TestBed, ComponentFixture, async, fakeAsync, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Observable } from 'rxjs/Rx'; +import { CoreModule, AlfrescoApiService, TranslationService, NotificationService } from 'ng2-alfresco-core'; + +import { NodeRestoreDirective } from './node-restore.directive'; + +@Component({ + template: `
` +}) +class TestComponent { + selection = []; +} + +describe('NodeRestoreDirective', () => { + let fixture: ComponentFixture; + let element: DebugElement; + let component: TestComponent; + let alfrescoService: AlfrescoApiService; + let translation: TranslationService; + let notificationService: NotificationService; + let router: Router; + let nodesService; + let coreApi; + let directiveInstance; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CoreModule, + RouterTestingModule + ], + declarations: [ + TestComponent, + NodeRestoreDirective + ] + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + element = fixture.debugElement.query(By.directive(NodeRestoreDirective)); + directiveInstance = element.injector.get(NodeRestoreDirective); + + alfrescoService = TestBed.get(AlfrescoApiService); + nodesService = alfrescoService.getInstance().nodes; + coreApi = alfrescoService.getInstance().core; + translation = TestBed.get(TranslationService); + notificationService = TestBed.get(NotificationService); + router = TestBed.get(Router); + }); + })); + + beforeEach(() => { + spyOn(translation, 'get').and.returnValue(Observable.of('message')); + }); + + it('does not restore nodes if no selection', () => { + spyOn(nodesService, 'restoreNode'); + + component.selection = []; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(nodesService.restoreNode).not.toHaveBeenCalled(); + }); + + it('does not restore nodes if selection has nodes without path', () => { + spyOn(nodesService, 'restoreNode'); + + component.selection = [ { entry: { id: '1' } } ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(nodesService.restoreNode).not.toHaveBeenCalled(); + }); + + it('call restore nodes if selection has nodes with path', fakeAsync(() => { + spyOn(directiveInstance, 'restoreNotification').and.callFake(() => null); + spyOn(nodesService, 'restoreNode').and.returnValue(Promise.resolve()); + spyOn(coreApi.nodesApi, 'getDeletedNodes').and.returnValue(Promise.resolve({ + list: { entries: [] } + })); + + component.selection = [{ entry: { id: '1', path: ['somewhere-over-the-rainbow'] } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(nodesService.restoreNode).toHaveBeenCalled(); + })); + + describe('refresh()', () => { + it('reset selection', fakeAsync(() => { + spyOn(directiveInstance, 'restoreNotification').and.callFake(() => null); + spyOn(nodesService, 'restoreNode').and.returnValue(Promise.resolve()); + spyOn(coreApi.nodesApi, 'getDeletedNodes').and.returnValue(Promise.resolve({ + list: { entries: [] } + })); + + component.selection = [{ entry: { id: '1', path: ['somewhere-over-the-rainbow'] } }]; + + fixture.detectChanges(); + + expect(directiveInstance.selection.length).toBe(1); + + element.triggerEventHandler('click', null); + tick(); + + expect(directiveInstance.selection.length).toBe(0); + })); + + it('reset status', fakeAsync(() => { + directiveInstance.restoreProcessStatus.fail = [{}]; + directiveInstance.restoreProcessStatus.success = [{}]; + + directiveInstance.restoreProcessStatus.reset(); + + expect(directiveInstance.restoreProcessStatus.fail).toEqual([]); + expect(directiveInstance.restoreProcessStatus.success).toEqual([]); + })); + + it('dispatch event on finish', fakeAsync(() => { + spyOn(directiveInstance, 'restoreNotification').and.callFake(() => null); + spyOn(nodesService, 'restoreNode').and.returnValue(Promise.resolve()); + spyOn(coreApi.nodesApi, 'getDeletedNodes').and.returnValue(Promise.resolve({ + list: { entries: [] } + })); + spyOn(element.nativeElement, 'dispatchEvent'); + + component.selection = [{ entry: { id: '1', path: ['somewhere-over-the-rainbow'] } }]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(element.nativeElement.dispatchEvent).toHaveBeenCalled(); + })); + }); + + describe('notification', () => { + beforeEach(() => { + spyOn(coreApi.nodesApi, 'getDeletedNodes').and.returnValue(Promise.resolve({ + list: { entries: [] } + })); + }); + + it('notifies on partial multiple fail ', fakeAsync(() => { + const error = { message: '{ "error": {} }' }; + + spyOn(notificationService, 'openSnackMessageAction').and.returnValue({ onAction: () => Observable.throw(null) }); + + spyOn(nodesService, 'restoreNode').and.callFake((id) => { + if (id === '1') { + return Promise.resolve(); + } + + if (id === '2') { + return Promise.reject(error); + } + + if (id === '3') { + return Promise.reject(error); + } + }); + + component.selection = [ + { entry: { id: '1', name: 'name1', path: ['somewhere-over-the-rainbow'] } }, + { entry: { id: '2', name: 'name2', path: ['somewhere-over-the-rainbow'] } }, + { entry: { id: '3', name: 'name3', path: ['somewhere-over-the-rainbow'] } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(translation.get).toHaveBeenCalledWith( + 'APP.MESSAGES.ERRORS.TRASH.NODES_RESTORE.PARTIAL_PLURAL', + { number: 2 } + ); + })); + + it('notifies fail when restored node exist, error 409', fakeAsync(() => { + const error = { message: '{ "error": { "statusCode": 409 } }' }; + + spyOn(notificationService, 'openSnackMessageAction').and.returnValue({ onAction: () => Observable.throw(null) }); + spyOn(nodesService, 'restoreNode').and.returnValue(Promise.reject(error)); + + component.selection = [ + { entry: { id: '1', name: 'name1', path: ['somewhere-over-the-rainbow'] } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(translation.get).toHaveBeenCalledWith( + 'APP.MESSAGES.ERRORS.TRASH.NODES_RESTORE.NODE_EXISTS', + { name: 'name1' } + ); + })); + + it('notifies fail when restored node returns different statusCode', fakeAsync(() => { + const error = { message: '{ "error": { "statusCode": 404 } }' }; + + spyOn(notificationService, 'openSnackMessageAction').and.returnValue({ onAction: () => Observable.throw(null) }); + spyOn(nodesService, 'restoreNode').and.returnValue(Promise.reject(error)); + + component.selection = [ + { entry: { id: '1', name: 'name1', path: ['somewhere-over-the-rainbow'] } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(translation.get).toHaveBeenCalledWith( + 'APP.MESSAGES.ERRORS.TRASH.NODES_RESTORE.GENERIC', + { name: 'name1' } + ); + })); + + it('notifies fail when restored node location is missing', fakeAsync(() => { + const error = { message: '{ "error": { } }' }; + + spyOn(notificationService, 'openSnackMessageAction').and.returnValue({ onAction: () => Observable.throw(null) }); + spyOn(nodesService, 'restoreNode').and.returnValue(Promise.reject(error)); + + component.selection = [ + { entry: { id: '1', name: 'name1', path: ['somewhere-over-the-rainbow'] } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(translation.get).toHaveBeenCalledWith( + 'APP.MESSAGES.ERRORS.TRASH.NODES_RESTORE.LOCATION_MISSING', + { name: 'name1' } + ); + })); + + it('notifies success when restore multiple nodes', fakeAsync(() => { + spyOn(notificationService, 'openSnackMessageAction').and.returnValue({ onAction: () => Observable.throw(null) }); + spyOn(nodesService, 'restoreNode').and.callFake((id) => { + if (id === '1') { + return Promise.resolve(); + } + + if (id === '2') { + return Promise.resolve(); + } + }); + + component.selection = [ + { entry: { id: '1', name: 'name1', path: ['somewhere-over-the-rainbow'] } }, + { entry: { id: '2', name: 'name2', path: ['somewhere-over-the-rainbow'] } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(translation.get).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.TRASH.NODES_RESTORE.PLURAL' + ); + })); + + it('notifies success when restore selected node', fakeAsync(() => { + spyOn(notificationService, 'openSnackMessageAction').and.returnValue({ onAction: () => Observable.throw(null) }); + spyOn(nodesService, 'restoreNode').and.returnValue(Promise.resolve()); + + component.selection = [ + { entry: { id: '1', name: 'name1', path: ['somewhere-over-the-rainbow'] } } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(translation.get).toHaveBeenCalledWith( + 'APP.MESSAGES.INFO.TRASH.NODES_RESTORE.SINGULAR', + { name: 'name1' } + ); + })); + + it('navigate to restore selected node location onAction', fakeAsync(() => { + spyOn(router, 'navigate'); + spyOn(nodesService, 'restoreNode').and.returnValue(Promise.resolve()); + spyOn(notificationService, 'openSnackMessageAction').and.returnValue({ onAction: () => Observable.of({}) }); + + component.selection = [ + { + entry: { + id: '1', + name: 'name1', + path: { + elements: ['somewhere-over-the-rainbow'] + } + } + } + ]; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + tick(); + + expect(router.navigate).toHaveBeenCalled(); + })); + }); +}); diff --git a/src/app/common/directives/node-restore.directive.ts b/src/app/common/directives/node-restore.directive.ts new file mode 100644 index 000000000..a6f87fc80 --- /dev/null +++ b/src/app/common/directives/node-restore.directive.ts @@ -0,0 +1,259 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Directive, ElementRef, HostListener, Input } from '@angular/core'; +import { Router } from '@angular/router'; +import { Observable } from 'rxjs/Rx'; + +import { TranslationService, AlfrescoApiService, NotificationService } from 'ng2-alfresco-core'; +import { MinimalNodeEntity, DeletedNodeEntry, PathInfoEntity } from 'alfresco-js-api'; + +@Directive({ + selector: '[app-restore-node]' +}) +export class NodeRestoreDirective { + private restoreProcessStatus; + + @Input('app-restore-node') + selection: MinimalNodeEntity[]; + + @HostListener('click') + onClick() { + this.restore(this.selection); + } + + constructor( + private alfrescoApiService: AlfrescoApiService, + private translation: TranslationService, + private router: Router, + private notification: NotificationService, + private el: ElementRef + ) { + this.restoreProcessStatus = this.processStatus(); + } + + private restore(selection: any) { + if (!selection.length) { + return; + } + + const nodesWithPath = this.getNodesWithPath(selection); + + if (selection.length && !nodesWithPath.length) { + this.restoreProcessStatus.fail.push(...selection); + this.restoreNotification(); + this.refresh(); + return; + } + + this.restoreNodesBatch(nodesWithPath) + .do((restoredNodes) => { + const status = this.processStatus(restoredNodes); + + this.restoreProcessStatus.fail.push(...status.fail); + this.restoreProcessStatus.success.push(...status.success); + }) + .flatMap(() => this.getDeletedNodes()) + .subscribe( + (deletedNodesList: any) => { + const { entries: nodelist } = deletedNodesList.list; + const { fail: restoreErrorNodes } = this.restoreProcessStatus; + const selectedNodes = this.diff(restoreErrorNodes, selection, false); + const remainingNodes = this.diff(selectedNodes, nodelist); + + if (!remainingNodes.length) { + this.restoreNotification(); + this.refresh(); + } else { + this.restore(remainingNodes); + } + } + ); + } + + private restoreNodesBatch(batch: MinimalNodeEntity[]): Observable { + return Observable.forkJoin(batch.map((node) => this.restoreNode(node))); + } + + private getNodesWithPath(selection): MinimalNodeEntity[] { + return selection.filter((node) => node.entry.path); + } + + private getDeletedNodes(): Observable { + const promise = this.alfrescoApiService.getInstance() + .core.nodesApi.getDeletedNodes({ include: [ 'path' ] }); + + return Observable.from(promise); + } + + private restoreNode(node): Observable { + const { entry } = node; + + const promise = this.alfrescoApiService.getInstance().nodes.restoreNode(entry.id); + + return Observable.from(promise) + .map(() => ({ + status: 1, + entry + })) + .catch((error) => { + const { statusCode } = (JSON.parse(error.message)).error; + + return Observable.of({ + status: 0, + statusCode, + entry + }); + }); + } + + private navigateLocation(path: PathInfoEntity) { + const parent = path.elements[path.elements.length - 1]; + + this.router.navigate([ '/personal-files', parent.id ]); + } + + private diff(selection , list, fromList = true): any { + const ids = selection.map(item => item.entry.id); + + return list.filter(item => { + if (fromList) { + return ids.includes(item.entry.id) ? item : null; + } else { + return !ids.includes(item.entry.id) ? item : null; + } + }); + } + + private processStatus(data = []): any { + const status = { + fail: [], + success: [], + get someFailed() { + return !!(this.fail.length); + }, + get someSucceeded() { + return !!(this.success.length); + }, + get oneFailed() { + return this.fail.length === 1; + }, + get oneSucceeded() { + return this.success.length === 1; + }, + get allSucceeded() { + return this.someSucceeded && !this.someFailed; + }, + get allFailed() { + return this.someFailed && !this.someSucceeded; + }, + reset() { + this.fail = []; + this.success = []; + } + }; + + return data.reduce( + (acc, node) => { + if (node.status) { + acc.success.push(node); + } else { + acc.fail.push(node); + } + + return acc; + }, + status + ); + } + + private getRestoreMessage(): Observable { + const { restoreProcessStatus: status } = this; + + if (status.someFailed && !status.oneFailed) { + return this.translation.get( + 'APP.MESSAGES.ERRORS.TRASH.NODES_RESTORE.PARTIAL_PLURAL', + { + number: status.fail.length + } + ); + } + + if (status.oneFailed && status.fail[0].statusCode) { + if (status.fail[0].statusCode === 409) { + return this.translation.get( + 'APP.MESSAGES.ERRORS.TRASH.NODES_RESTORE.NODE_EXISTS', + { + name: status.fail[0].entry.name + } + ); + } else { + return this.translation.get( + 'APP.MESSAGES.ERRORS.TRASH.NODES_RESTORE.GENERIC', + { + name: status.fail[0].entry.name + } + ); + } + } + + if (status.oneFailed && !status.fail[0].statusCode) { + return this.translation.get( + 'APP.MESSAGES.ERRORS.TRASH.NODES_RESTORE.LOCATION_MISSING', + { + name: status.fail[0].entry.name + } + ); + } + + if (status.allSucceeded && !status.oneSucceeded) { + return this.translation.get('APP.MESSAGES.INFO.TRASH.NODES_RESTORE.PLURAL'); + } + + if (status.allSucceeded && status.oneSucceeded) { + return this.translation.get( + 'APP.MESSAGES.INFO.TRASH.NODES_RESTORE.SINGULAR', + { + name: status.success[0].entry.name + } + ); + } + } + + private restoreNotification(): void { + const status = Object.assign({}, this.restoreProcessStatus); + const action = (status.oneSucceeded && !status.someFailed) ? 'View' : ''; + + this.getRestoreMessage() + .subscribe((message) => { + this.notification.openSnackMessageAction(message, action, 3000) + .onAction() + .subscribe(() => this.navigateLocation(status.success[0].entry.path)); + }); + } + + private refresh(): void { + this.restoreProcessStatus.reset(); + this.selection = []; + this.emitDone(); + } + + private emitDone() { + const e = new CustomEvent('selection-node-restored', { bubbles: true }); + this.el.nativeElement.dispatchEvent(e); + } +} diff --git a/src/app/common/material.module.ts b/src/app/common/material.module.ts new file mode 100644 index 000000000..f4ac8de7b --- /dev/null +++ b/src/app/common/material.module.ts @@ -0,0 +1,41 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NgModule } from '@angular/core'; +import { + MdMenuModule, + MdIconModule, + MdButtonModule, + MdDialogModule, + MdInputModule +} from '@angular/material'; + +export function modules() { + return [ + MdMenuModule, + MdIconModule, + MdButtonModule, + MdDialogModule, + MdInputModule + ]; +} + +@NgModule({ + imports: modules(), + exports: modules() +}) +export class MaterialModule {} diff --git a/src/app/common/pipes/node-name-tooltip.pipe.spec.ts b/src/app/common/pipes/node-name-tooltip.pipe.spec.ts new file mode 100644 index 000000000..aec9d63c3 --- /dev/null +++ b/src/app/common/pipes/node-name-tooltip.pipe.spec.ts @@ -0,0 +1,145 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MinimalNodeEntity } from 'alfresco-js-api'; +import { NodeNameTooltipPipe } from './node-name-tooltip.pipe'; + +describe('NodeNameTooltipPipe', () => { + + const nodeName = 'node-name'; + const nodeTitle = 'node-title'; + const nodeDescription = 'node-description'; + + let pipe: NodeNameTooltipPipe; + + beforeEach(() => { + pipe = new NodeNameTooltipPipe(); + }); + + it('should not transform when missing node', () => { + expect(pipe.transform(null)).toBe(null); + }); + + it('should not transform when missing node entry', () => { + expect(pipe.transform( {})).toBe(null); + }); + + it('should use title and description when all fields present', () => { + const node: any = { + entry: { + name: nodeName, + properties: { + 'cm:title': nodeTitle, + 'cm:description': nodeDescription + } + } + }; + let tooltip = pipe.transform(node); + expect(tooltip).toBe(`${nodeTitle}\n${nodeDescription}`); + }); + + it('should use name when other properties are missing', () => { + const node = { + entry: { + name: nodeName + } + }; + let tooltip = pipe.transform( node); + expect(tooltip).toBe(nodeName); + }); + + it('should display name when title and description are missing', () => { + const node: any = { + entry: { + name: nodeName, + properties: {} + } + }; + let tooltip = pipe.transform(node); + expect(tooltip).toBe(nodeName); + }); + + it('should use name and description when title is missing', () => { + const node: any = { + entry: { + name: nodeName, + properties: { + 'cm:title': null, + 'cm:description': nodeDescription + } + } + }; + let tooltip = pipe.transform(node); + expect(tooltip).toBe(`${nodeName}\n${nodeDescription}`); + }); + + it('should use name and title when description is missing', () => { + const node: any = { + entry: { + name: nodeName, + properties: { + 'cm:title': nodeTitle, + 'cm:description': null + } + } + }; + let tooltip = pipe.transform(node); + expect(tooltip).toBe(`${nodeName}\n${nodeTitle}`); + }); + + it('should use name if name and description are the same', () => { + const node: any = { + entry: { + name: nodeName, + properties: { + 'cm:title': null, + 'cm:description': nodeName + } + } + }; + let tooltip = pipe.transform(node); + expect(tooltip).toBe(nodeName); + }); + + it('should use name if name and title are the same', () => { + const node: any = { + entry: { + name: nodeName, + properties: { + 'cm:title': nodeName, + 'cm:description': null + } + } + }; + let tooltip = pipe.transform(node); + expect(tooltip).toBe(nodeName); + }); + + it('should use name if all values are the same', () => { + const node: any = { + entry: { + name: nodeName, + properties: { + 'cm:title': nodeName, + 'cm:description': nodeName + } + } + }; + let tooltip = pipe.transform(node); + expect(tooltip).toBe(nodeName); + }); +}); diff --git a/src/app/common/pipes/node-name-tooltip.pipe.ts b/src/app/common/pipes/node-name-tooltip.pipe.ts new file mode 100644 index 000000000..d1e5e398b --- /dev/null +++ b/src/app/common/pipes/node-name-tooltip.pipe.ts @@ -0,0 +1,79 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Pipe, PipeTransform } from '@angular/core'; +import { MinimalNodeEntity } from 'alfresco-js-api'; +import { DataRow } from 'ng2-alfresco-datatable'; + +@Pipe({ + name: 'nodeNameTooltip' +}) +export class NodeNameTooltipPipe implements PipeTransform { + + transform(node: MinimalNodeEntity): string { + if (node) { + return this.getNodeTooltip(node); + } + return null; + } + + private containsLine(lines: string[], line: string): boolean { + return lines.some((item: string) => { + return item.toLowerCase() === line.toLowerCase(); + }); + } + + private removeDuplicateLines(lines: string[]): string[] { + const reducer = (acc: string[], line: string): string[] => { + if (!this.containsLine(acc, line)) { acc.push(line); } + return acc; + }; + + return lines.reduce(reducer, []); + } + + private getNodeTooltip(node: MinimalNodeEntity): string { + if (!node || !node.entry) { + return null; + } + + const { entry: { properties, name } } = node; + const lines = [ name ]; + + if (properties) { + const { + 'cm:title': title, + 'cm:description': description + } = properties; + + if (title && description) { + lines[0] = title; + lines[1] = description; + } + + if (title) { + lines[1] = title; + } + + if (description) { + lines[1] = description; + } + } + + return this.removeDuplicateLines(lines).join(`\n`); + } +} diff --git a/src/app/common/services/browsing-files.service.spec.ts b/src/app/common/services/browsing-files.service.spec.ts new file mode 100644 index 000000000..0d415ac40 --- /dev/null +++ b/src/app/common/services/browsing-files.service.spec.ts @@ -0,0 +1,36 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BrowsingFilesService } from './browsing-files.service'; + +describe('BrowsingFilesService', () => { + let service: BrowsingFilesService; + + beforeEach(() => { + service = new BrowsingFilesService(); + }); + + it('subscribs to event', () => { + const value = 'test-value'; + + service.onChangeParent.subscribe((result) => { + expect(result).toBe(value); + }); + + service.onChangeParent.next(value); + }); +}); diff --git a/src/app/common/services/browsing-files.service.ts b/src/app/common/services/browsing-files.service.ts new file mode 100644 index 000000000..cca3528f7 --- /dev/null +++ b/src/app/common/services/browsing-files.service.ts @@ -0,0 +1,26 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Subject } from 'rxjs/Subject'; +import { Injectable } from '@angular/core'; + +import { MinimalNodeEntryEntity } from 'alfresco-js-api'; + +@Injectable() +export class BrowsingFilesService { + onChangeParent = new Subject(); +} diff --git a/src/app/common/services/content-management.service.ts b/src/app/common/services/content-management.service.ts new file mode 100644 index 000000000..3fcc47199 --- /dev/null +++ b/src/app/common/services/content-management.service.ts @@ -0,0 +1,31 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Subject } from 'rxjs/Rx'; +import { Injectable } from '@angular/core'; + +import { MinimalNodeEntryEntity } from 'alfresco-js-api'; + +@Injectable() +export class ContentManagementService { + createFolder = new Subject(); + editFolder = new Subject(); + deleteNode = new Subject(); + moveNode = new Subject(); + restoreNode = new Subject(); + toggleFavorite = new Subject(); +} diff --git a/src/app/common/services/node-actions.service.spec.ts b/src/app/common/services/node-actions.service.spec.ts new file mode 100644 index 000000000..2316a2723 --- /dev/null +++ b/src/app/common/services/node-actions.service.spec.ts @@ -0,0 +1,1156 @@ +import { TestBed, async } from '@angular/core/testing'; +import { MdDialog, OverlayModule } from '@angular/material'; +import { Observable } from 'rxjs/Rx'; +import { CoreModule, AlfrescoApiService, NodesApiService } from 'ng2-alfresco-core'; +import { DocumentListService, NodeMinimal, NodeMinimalEntry } from 'ng2-alfresco-documentlist'; +import { NodeActionsService } from './node-actions.service'; + +class TestNode extends NodeMinimalEntry { + constructor(id?: string, isFile?: boolean, name?: string, permission?: string[]) { + super(); + this.entry = new NodeMinimal(); + this.entry.id = id || 'node-id'; + this.entry.isFile = isFile; + this.entry.isFolder = !isFile; + this.entry.nodeType = isFile ? 'content' : 'folder'; + this.entry.name = name; + if (permission) { + this.entry['allowableOperations'] = permission; + } + } +} + +describe('NodeActionsService', () => { + const actionIsForbidden = true; + const isFile = true; + const folderDestinationId = 'folder-destination-id'; + const fileId = 'file-to-be-copied-id'; + const conflictError = new Error(JSON.stringify({error: {statusCode: 409}})); + const permissionError = new Error(JSON.stringify({error: {statusCode: 403}})); + const badRequestError = new Error(JSON.stringify({error: {statusCode: 400}})); + const emptyChildrenList = {list: {entries: []}}; + let service: NodeActionsService; + let apiService: AlfrescoApiService; + let nodesApiService: NodesApiService; + let nodesApi; + let spyOnSuccess = jasmine.createSpy('spyOnSuccess'); + let spyOnError = jasmine.createSpy('spyOnError'); + + let helper = { + fakeCopyNode: (isForbidden: boolean = false, nameExistingOnDestination?: string) => { + return (entryId, options) => { + return new Promise((resolve, reject) => { + + if (isForbidden) { + reject(permissionError); + + } else if (nameExistingOnDestination && options && options.name === nameExistingOnDestination) { + + reject(conflictError); + + } else { + resolve(); + } + }); + }; + }, + fakeGetNodeChildren: (familyNodes: { parentNodeId: string, nodeChildren: any[] }[], isForbidden: boolean = false) => { + return (parentId, options) => { + + return new Promise((resolve, reject) => { + if (isForbidden) { + reject(permissionError); + + } else { + const node = familyNodes.filter(familyNode => familyNode.parentNodeId === parentId); + resolve({list: {entries: node[0].nodeChildren}} || emptyChildrenList); + } + }); + + }; + } + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CoreModule, + OverlayModule + ], + providers: [ + MdDialog, + DocumentListService, + AlfrescoApiService, + NodesApiService, + NodeActionsService] + }); + + service = TestBed.get(NodeActionsService); + apiService = TestBed.get(AlfrescoApiService); + nodesApiService = TestBed.get(NodesApiService); + nodesApi = apiService.getInstance().nodes; + }); + + describe('doBatchOperation', () => { + beforeEach(() => { + spyOnSuccess.calls.reset(); + spyOnError.calls.reset(); + }); + + it('should throw error if \'contentEntities\' required parameter is missing', async(() => { + let contentEntities; + const doCopyBatchOperation = service.copyNodes(contentEntities).asObservable(); + + doCopyBatchOperation.toPromise().then( + () => { + spyOnSuccess(); + }, + (error) => { + spyOnError(error); + }) + .then(() => { + expect(spyOnSuccess).not.toHaveBeenCalled(); + expect(spyOnError).toHaveBeenCalled(); + } + ); + })); + + it('should throw error if \'contentEntities\' is not an array of entry entities', async(() => { + const contentEntities = [ new TestNode(), {} ]; + const doCopyBatchOperation = service.copyNodes(contentEntities).asObservable(); + + doCopyBatchOperation.toPromise().then( + () => { + spyOnSuccess(); + }, + (error) => { + spyOnError(error); + }) + .then(() => { + expect(spyOnSuccess).not.toHaveBeenCalled(); + expect(spyOnError).toHaveBeenCalledWith(badRequestError); + } + ); + })); + + it('should throw error if an entry in \'contentEntities\' does not have id nor nodeId property', async(() => { + const contentEntities = [ new TestNode(), {entry: {}} ]; + const doCopyBatchOperation = service.copyNodes(contentEntities).asObservable(); + + doCopyBatchOperation.toPromise().then( + () => { + spyOnSuccess(); + }, + (error) => { + spyOnError(error); + }) + .then(() => { + expect(spyOnSuccess).not.toHaveBeenCalled(); + expect(spyOnError).toHaveBeenCalledWith(badRequestError); + } + ); + })); + + it('should not throw error if entry in \'contentEntities\' does not have id, but has nodeId property', async(() => { + const contentEntities = [ new TestNode(), {entry: {nodeId: '1234'}} ]; + + spyOn(service, 'getContentNodeSelection').and.returnValue(Observable.of([new TestNode().entry])); + spyOn(service, 'copyNodeAction').and.returnValue(Observable.of({})); + + const doCopyBatchOperation = service.copyNodes(contentEntities).asObservable(); + + doCopyBatchOperation.toPromise().then( + () => { + spyOnSuccess(); + }, + (error) => { + spyOnError(error); + }) + .then(() => { + expect(spyOnSuccess).toHaveBeenCalled(); + expect(spyOnError).not.toHaveBeenCalledWith(badRequestError); + } + ); + + })); + }); + + describe('getFirstParentId', () => { + it('should give the parentId, if that exists on the node entry', () => { + const parentID = 'parent-id'; + const contentEntities = [ {entry: {nodeId: '1234', parentId: parentID}} ]; + + expect(service.getFirstParentId(contentEntities)).toBe(parentID); + }); + + it('should give the last element in path property, if parentId is missing and path exists on the node entry', () => { + const firstParentId = 'parent-0-id'; + const contentEntities = [ {entry: {nodeId: '1234', path: {elements: [ {id: 'parent-1-id'}, { id: firstParentId} ]} }} ]; + + expect(service.getFirstParentId(contentEntities)).toBe(firstParentId); + }); + + it('should give the id of the first node entry, if none of nodes has either parentId, or path properties', () => { + const nodeID = '1234'; + const contentEntities = [ {entry: {id: nodeID}}, {entry: {id: `${nodeID}-2`}} ]; + + expect(service.getFirstParentId(contentEntities)).toBe(nodeID); + }); + + it('should give the nodeId of the first node entry, if none of nodes has either parentId, or path properties', () => { + const nodeID = '1234'; + const contentEntities = [ {entry: {nodeId: nodeID}}, {entry: {id: `${nodeID}-2`}} ]; + + expect(service.getFirstParentId(contentEntities)).toBe(nodeID); + }); + }); + + describe('getEntryParentId', () => { + it('should return the parentId, if that exists on the node entry', () => { + const parentID = 'parent-id'; + const entry = {nodeId: '1234', parentId: parentID}; + + expect(service.getEntryParentId(entry)).toBe(parentID); + }); + + it('should give the last element in path property, if parentId is missing and path exists on the node entry', () => { + const firstParentId = 'parent-0-id'; + const entry = {nodeId: '1234', path: {elements: [ {id: 'parent-1-id'}, { id: firstParentId} ]} }; + + expect(service.getEntryParentId(entry)).toBe(firstParentId); + }); + }); + + describe('copyNodes', () => { + let fileToCopy; + let folderToCopy; + let destinationFolder; + + beforeEach(() => { + fileToCopy = new TestNode(fileId, isFile, 'file-name'); + folderToCopy = new TestNode(); + destinationFolder = new TestNode(folderDestinationId); + + }); + + it('should be called', () => { + const spyOnBatchOperation = spyOn(service, 'doBatchOperation').and.callThrough(); + spyOn(service, 'getContentNodeSelection').and.returnValue(Observable.of([destinationFolder.entry])); + spyOn(service, 'copyNodeAction').and.returnValue(Observable.of({})); + + service.copyNodes([fileToCopy, folderToCopy]); + expect(spyOnBatchOperation.calls.count()).toEqual(1); + expect(spyOnBatchOperation).toHaveBeenCalledWith('copy', [fileToCopy, folderToCopy], undefined); + }); + + it('should use the ContentNodeSelectorComponentData object with custom rowFilter & imageResolver & title, when opening the destination picker', () => { + const spyOnBatchOperation = spyOn(service, 'doBatchOperation').and.callThrough(); + const spyOnDestinationPicker = spyOn(service, 'getContentNodeSelection').and.callThrough(); + spyOn(service, 'getFirstParentId').and.returnValue('parent-id'); + + let testContentNodeSelectorComponentData; + const dialog = TestBed.get(MdDialog); + const spyOnDialog = spyOn(dialog, 'open').and.callFake((contentNodeSelectorComponent: any, data: any) => { + testContentNodeSelectorComponentData = data; + return {}; + }); + + service.copyNodes([fileToCopy, folderToCopy]); + + expect(spyOnBatchOperation).toHaveBeenCalledWith('copy', [fileToCopy, folderToCopy], undefined); + expect(spyOnDestinationPicker.calls.count()).toEqual(1); + expect(spyOnDialog.calls.count()).toEqual(1); + + expect(testContentNodeSelectorComponentData).toBeDefined(); + expect(testContentNodeSelectorComponentData.data.rowFilter({node: destinationFolder})).toBeDefined(); + expect(testContentNodeSelectorComponentData.data.imageResolver({node: destinationFolder})).toBeDefined(); + expect(testContentNodeSelectorComponentData.data.title).toBe('copy to ...'); + + destinationFolder.entry['allowableOperations'] = ['update']; + expect(testContentNodeSelectorComponentData.data.imageResolver({node: destinationFolder})).toBeDefined(); + }); + + it('should use the ContentNodeSelectorComponentData object with file name in title', () => { + const spyOnBatchOperation = spyOn(service, 'doBatchOperation').and.callThrough(); + spyOn(service, 'getContentNodeSelection').and.callThrough(); + spyOn(service, 'getFirstParentId').and.returnValue('parent-id'); + + let testContentNodeSelectorComponentData; + const dialog = TestBed.get(MdDialog); + spyOn(dialog, 'open').and.callFake((contentNodeSelectorComponent: any, data: any) => { + testContentNodeSelectorComponentData = data; + return {}; + }); + + service.copyNodes([{entry: {id: 'entry-id', name: 'entry-name'}}]); + + expect(spyOnBatchOperation).toHaveBeenCalled(); + expect(testContentNodeSelectorComponentData).toBeDefined(); + expect(testContentNodeSelectorComponentData.data.title).toBe('copy entry-name to ...'); + }); + + it('should use the ContentNodeSelectorComponentData object without file name in title, if no name exists', () => { + const spyOnBatchOperation = spyOn(service, 'doBatchOperation').and.callThrough(); + spyOn(service, 'getContentNodeSelection').and.callThrough(); + spyOn(service, 'getFirstParentId').and.returnValue('parent-id'); + + let testContentNodeSelectorComponentData; + const dialog = TestBed.get(MdDialog); + spyOn(dialog, 'open').and.callFake((contentNodeSelectorComponent: any, data: any) => { + testContentNodeSelectorComponentData = data; + return {}; + }); + + service.copyNodes([{entry: {id: 'entry-id'}}]); + + expect(spyOnBatchOperation).toHaveBeenCalled(); + expect(testContentNodeSelectorComponentData).toBeDefined(); + expect(testContentNodeSelectorComponentData.data.title).toBe('copy to ...'); + }); + + }); + + describe('copyNodeAction', () => { + + it('should copy one folder node to destination', () => { + spyOn(nodesApi, 'copyNode').and.callFake(helper.fakeCopyNode()); + + let folderToCopy = new TestNode(); + let folderDestination = new TestNode(folderDestinationId); + service.copyNodeAction(folderToCopy.entry, folderDestination.entry.id); + + expect(nodesApi.copyNode).toHaveBeenCalledWith( + folderToCopy.entry.id, + { targetParentId: folderDestination.entry.id, name: undefined } + ); + }); + + it('should copy one file node to destination', () => { + spyOn(nodesApi, 'copyNode').and.callFake(helper.fakeCopyNode()); + + let fileToCopy = new TestNode(fileId, isFile, 'file-name'); + let folderDestination = new TestNode(folderDestinationId); + service.copyNodeAction(fileToCopy.entry, folderDestination.entry.id); + + expect(nodesApi.copyNode).toHaveBeenCalledWith( + fileToCopy.entry.id, + { targetParentId: folderDestination.entry.id, name: 'file-name' } + ); + }); + + it('should fail to copy folder node if action is forbidden', async(() => { + spyOn(nodesApi, 'copyNode').and.callFake(helper.fakeCopyNode(actionIsForbidden)); + + let folderToCopy = new TestNode(); + let folderDestination = new TestNode(folderDestinationId); + + const spyContentAction = spyOn(service, 'copyContentAction').and.callThrough(); + const spyFolderAction = spyOn(service, 'copyFolderAction').and.callThrough(); + const copyObservable = service.copyNodeAction(folderToCopy.entry, folderDestination.entry.id); + + spyOnSuccess.calls.reset(); + spyOnError.calls.reset(); + copyObservable.toPromise().then( + () => { + spyOnSuccess(); + }, + () => { + spyOnError(); + + expect(spyContentAction.calls.count()).toEqual(0); + expect(spyFolderAction.calls.count()).toEqual(1); + expect(nodesApi.copyNode).toHaveBeenCalledWith( + folderToCopy.entry.id, + { targetParentId: folderDestination.entry.id, name: undefined } + ); + }).then(() => { + expect(spyOnSuccess.calls.count()).toEqual(0); + expect(spyOnError.calls.count()).toEqual(1); + }); + })); + + it('should fail to copy file node if action is forbidden', async(() => { + spyOn(nodesApi, 'copyNode').and.callFake(helper.fakeCopyNode(actionIsForbidden)); + + const spyContentAction = spyOn(service, 'copyContentAction').and.callThrough(); + const spyFolderAction = spyOn(service, 'copyFolderAction').and.callThrough(); + + let fileToCopy = new TestNode(fileId, isFile, 'test-name'); + let folderDestination = new TestNode(folderDestinationId); + const copyObservable = service.copyNodeAction(fileToCopy.entry, folderDestination.entry.id); + + spyOnSuccess.calls.reset(); + spyOnError.calls.reset(); + copyObservable.toPromise() + .then( + () => { + spyOnSuccess(); + }, + () => { + spyOnError(); + }) + .then( + () => { + expect(spyOnSuccess).not.toHaveBeenCalled(); + expect(spyOnError).toHaveBeenCalled(); + + expect(spyContentAction).toHaveBeenCalled(); + expect(spyFolderAction).not.toHaveBeenCalled(); + expect(nodesApi.copyNode).toHaveBeenCalledWith( + fileToCopy.entry.id, + {targetParentId: folderDestination.entry.id, name: 'test-name'} + ); + }); + })); + + it('should copy one file node to same destination and autoRename it', async(() => { + const alreadyExistingName = 'file-name'; + spyOn(nodesApi, 'copyNode').and.callFake(helper.fakeCopyNode(!actionIsForbidden, alreadyExistingName)); + + const spyContentAction = spyOn(service, 'copyContentAction').and.callThrough(); + + let fileToCopy = new TestNode(fileId, isFile, 'file-name'); + let folderDestination = new TestNode(folderDestinationId); + const copyObservable = service.copyNodeAction(fileToCopy.entry, folderDestination.entry.id); + + spyOnSuccess.calls.reset(); + spyOnError.calls.reset(); + copyObservable.toPromise() + .then( + () => { + spyOnSuccess(); + }, + () => { + spyOnError(); + + }) + .then(() => { + expect(spyOnSuccess).toHaveBeenCalled(); + expect(spyOnError).not.toHaveBeenCalled(); + + expect(spyContentAction.calls.count()).toEqual(2); + expect(nodesApi.copyNode).toHaveBeenCalledWith( + fileToCopy.entry.id, + {targetParentId: folderDestination.entry.id, name: 'file-name-1'} + ); + }); + })); + + describe('should copy content of folder-to-copy to folder with same name from destination folder', () => { + let folderToCopy; + let fileChildOfFolderToCopy; + let folderParentAndDestination; + let existingFolder; + let spy; + let spyOnContentAction; + let spyOnFolderAction; + let copyObservable; + + beforeEach(() => { + folderToCopy = new TestNode('folder-to-copy-id', !isFile, 'conflicting-name'); + fileChildOfFolderToCopy = new TestNode(fileId, isFile, 'file-name'); + + folderParentAndDestination = new TestNode(folderDestinationId); + existingFolder = new TestNode('existing-folder-id', !isFile, 'conflicting-name'); + + spy = spyOn(nodesApi, 'copyNode').and.callFake(helper.fakeCopyNode(!actionIsForbidden, 'conflicting-name')); + + spyOnContentAction = spyOn(service, 'copyContentAction').and.callThrough(); + spyOnFolderAction = spyOn(service, 'copyFolderAction').and.callThrough(); + + copyObservable = service.copyNodeAction(folderToCopy.entry, folderParentAndDestination.entry.id); + spyOnSuccess.calls.reset(); + spyOnError.calls.reset(); + }); + + it('when folder to copy has a file as content', async(() => { + const testFamilyNodes = [ + { + parentNodeId: folderToCopy.entry.id, + nodeChildren: [fileChildOfFolderToCopy] + }, { + parentNodeId: folderParentAndDestination.entry.id, + nodeChildren: [existingFolder] + } + ]; + spyOn(nodesApi, 'getNodeChildren').and.callFake(helper.fakeGetNodeChildren(testFamilyNodes)); + spyOn(service, 'getChildByName').and.returnValue(Observable.of(existingFolder)); + + copyObservable.toPromise() + .then( + () => { + spyOnSuccess(); + }, + () => { + spyOnError(); + }) + .then( + () => { + expect(spyOnSuccess).toHaveBeenCalled(); + expect(spyOnError).not.toHaveBeenCalled(); + + expect(spyOnContentAction).toHaveBeenCalled(); + expect(spyOnFolderAction).toHaveBeenCalled(); + expect(spy.calls.allArgs()).toEqual([ + [folderToCopy.entry.id, { + targetParentId: folderParentAndDestination.entry.id, + name: 'conflicting-name' + }], + [fileChildOfFolderToCopy.entry.id, { + targetParentId: existingFolder.entry.id, + name: 'file-name' + }] + ]); + }); + })); + + it('when folder to copy is empty', async(() => { + const testFamilyNodes = [ + { + parentNodeId: folderToCopy.entry.id, + nodeChildren: [] + }, { + parentNodeId: folderParentAndDestination.entry.id, + nodeChildren: [existingFolder] + } + ]; + spyOn(nodesApi, 'getNodeChildren').and.callFake(helper.fakeGetNodeChildren(testFamilyNodes)); + spyOn(service, 'getChildByName').and.returnValue(Observable.of(existingFolder)); + + copyObservable.toPromise() + .then( + () => { + spyOnSuccess(); + }, + () => { + spyOnError(); + + }) + .then( + () => { + expect(spyOnSuccess).toHaveBeenCalled(); + expect(spyOnError).not.toHaveBeenCalled(); + + expect(spyOnContentAction).not.toHaveBeenCalled(); + expect(spyOnFolderAction).toHaveBeenCalled(); + expect(spy.calls.allArgs()).toEqual([ + [folderToCopy.entry.id, { + targetParentId: folderParentAndDestination.entry.id, + name: 'conflicting-name' + }] + ]); + }); + })); + + it('when folder to copy has another folder as child', async(() => { + const folderChild = new TestNode('folder-child-id'); + const testFamilyNodes = [ + { + parentNodeId: folderToCopy.entry.id, + nodeChildren: [folderChild] + }, { + parentNodeId: folderParentAndDestination.entry.id, + nodeChildren: [existingFolder] + } + ]; + spyOn(nodesApi, 'getNodeChildren').and.callFake(helper.fakeGetNodeChildren(testFamilyNodes)); + spyOn(service, 'getChildByName').and.returnValue(Observable.of(existingFolder)); + + copyObservable.toPromise() + .then( + () => { + spyOnSuccess(); + }, + () => { + spyOnError(); + }) + .then( + () => { + expect(spyOnSuccess).toHaveBeenCalled(); + expect(spyOnError).not.toHaveBeenCalled(); + + expect(spyOnContentAction).not.toHaveBeenCalled(); + expect(spyOnFolderAction.calls.count()).toEqual(2); + expect(spy.calls.allArgs()).toEqual([ + [folderToCopy.entry.id, { + targetParentId: folderParentAndDestination.entry.id, + name: 'conflicting-name' + }], + [folderChild.entry.id, { + targetParentId: existingFolder.entry.id, + name: undefined + }] + ]); + } + ); + })); + }); + + }); + + describe('moveNodes', () => { + const permissionToMove = 'delete'; + let fileToMove; + let folderToMove; + let destinationFolder; + let spyOnBatchOperation; + let spyOnDocumentListServiceAction; + let documentListService; + + beforeEach(() => { + fileToMove = new TestNode('file-to-be-moved', isFile, 'file-name'); + folderToMove = new TestNode('fid', !isFile, 'folder-name'); + destinationFolder = new TestNode(folderDestinationId); + + documentListService = TestBed.get(DocumentListService); + spyOnBatchOperation = spyOn(service, 'doBatchOperation').and.callThrough(); + }); + + it('should allow to select destination for nodes that have permission to be moved', () => { + const spyOnDestinationPicker = spyOn(service, 'getContentNodeSelection').and.returnValue(Observable.of([destinationFolder.entry])); + spyOn(service, 'moveContentAction').and.returnValue(Observable.of({})); + spyOn(service, 'moveFolderAction').and.returnValue(Observable.of({})); + + fileToMove.entry['allowableOperations'] = [permissionToMove]; + folderToMove.entry['allowableOperations'] = [permissionToMove]; + + service.moveNodes([fileToMove, folderToMove], permissionToMove); + expect(spyOnBatchOperation).toHaveBeenCalledWith('move', [fileToMove, folderToMove], permissionToMove); + expect(spyOnDestinationPicker).toHaveBeenCalled(); + }); + + it('should not allow to select destination for nodes that do not have permission to be moved', () => { + const spyOnDestinationPicker = spyOn(service, 'getContentNodeSelection').and.returnValue(Observable.of([destinationFolder.entry])); + + fileToMove.entry['allowableOperations'] = []; + folderToMove.entry['allowableOperations'] = []; + + service.moveNodes([fileToMove, folderToMove], permissionToMove); + expect(spyOnBatchOperation).toHaveBeenCalledWith('move', [fileToMove, folderToMove], permissionToMove); + expect(spyOnDestinationPicker).not.toHaveBeenCalled(); + }); + + it('should call the documentListService moveNode directly for moving a file that has permission to be moved', () => { + const spyOnDestinationPicker = spyOn(service, 'getContentNodeSelection').and.returnValue(Observable.of([destinationFolder.entry])); + fileToMove.entry['allowableOperations'] = [permissionToMove]; + spyOnDocumentListServiceAction = spyOn(documentListService, 'moveNode').and.returnValue(Observable.of([fileToMove])); + spyOn(service, 'moveNodeAction'); + + service.moveNodes([fileToMove], permissionToMove); + expect(service.moveNodeAction).not.toHaveBeenCalled(); + expect(spyOnDocumentListServiceAction).toHaveBeenCalled(); + }); + + describe('moveContentAction', () => { + + beforeEach(() => { + spyOnSuccess.calls.reset(); + spyOnError.calls.reset(); + }); + + it('should not throw error on conflict, to be able to show message in case of partial move of files', async(() => { + spyOnDocumentListServiceAction = spyOn(documentListService, 'moveNode').and + .returnValue(Observable.throw(conflictError)); + + const moveContentActionObservable = service.moveContentAction(fileToMove.entry, folderDestinationId); + moveContentActionObservable.toPromise() + .then( + (value) => { + spyOnSuccess(value); + }, + (error) => { + spyOnError(error); + + }) + .then(() => { + expect(spyOnDocumentListServiceAction).toHaveBeenCalled(); + + expect(spyOnSuccess).toHaveBeenCalledWith(conflictError); + expect(spyOnError).not.toHaveBeenCalledWith(conflictError); + }); + })); + + it('should throw permission error in case it occurs', async(() => { + spyOnDocumentListServiceAction = spyOn(documentListService, 'moveNode').and + .returnValue(Observable.throw(permissionError)); + + const moveContentActionObservable = service.moveContentAction(fileToMove.entry, folderDestinationId); + moveContentActionObservable.toPromise() + .then( + (value) => { + spyOnSuccess(value); + }, + (error) => { + spyOnError(error); + + }) + .then(() => { + expect(spyOnDocumentListServiceAction).toHaveBeenCalled(); + + expect(spyOnSuccess).not.toHaveBeenCalledWith(permissionError); + expect(spyOnError).toHaveBeenCalledWith(permissionError); + }); + })); + + it('in case of success, should return also the initial parent id of the moved node', async(() => { + const parentID = 'parent-id'; + fileToMove.entry['parentId'] = parentID; + fileToMove.entry['allowableOperations'] = [permissionToMove]; + spyOnDocumentListServiceAction = spyOn(documentListService, 'moveNode').and + .returnValue(Observable.of(fileToMove)); + + const moveContentActionObservable = service.moveContentAction(fileToMove.entry, folderDestinationId); + moveContentActionObservable.toPromise() + .then( + (value) => { + spyOnSuccess(value); + }, + (error) => { + spyOnError(error); + + }) + .then(() => { + expect(spyOnDocumentListServiceAction).toHaveBeenCalled(); + + expect(spyOnSuccess).toHaveBeenCalledWith({itemMoved: fileToMove, initialParentId: parentID}); + expect(spyOnError).not.toHaveBeenCalledWith(permissionError); + }); + })); + + }); + + describe('moveFolderAction', () => { + + beforeEach(() => { + spyOnSuccess.calls.reset(); + spyOnError.calls.reset(); + }); + + it('should throw permission error in case it occurs on folder move', async(() => { + spyOnDocumentListServiceAction = spyOn(documentListService, 'moveNode').and + .returnValue(Observable.throw(permissionError)); + + const moveFolderActionObservable = service.moveFolderAction(folderToMove.entry, folderDestinationId); + moveFolderActionObservable.toPromise() + .then( + () => { + spyOnSuccess(); + }, + (error) => { + spyOnError(error); + }) + .then(() => { + expect(spyOnDocumentListServiceAction).toHaveBeenCalled(); + + expect(spyOnSuccess).not.toHaveBeenCalled(); + expect(spyOnError).toHaveBeenCalledWith(permissionError); + }); + })); + + it('should not throw error on conflict in case it occurs on folder move', async(() => { + spyOnDocumentListServiceAction = spyOn(documentListService, 'moveNode').and + .returnValue(Observable.throw(conflictError)); + + const newDestination = new TestNode('new-destination', !isFile, folderToMove.entry.name); + spyOn(service, 'getChildByName').and.returnValue(Observable.of(newDestination)); + spyOn(service, 'getNodeChildren').and.returnValue(Observable.of(emptyChildrenList)); + + const moveFolderActionObservable = service.moveFolderAction(folderToMove.entry, folderDestinationId); + moveFolderActionObservable.toPromise() + .then( + () => { + spyOnSuccess(); + }, + (error) => { + spyOnError(error); + }) + .then(() => { + expect(spyOnDocumentListServiceAction).toHaveBeenCalled(); + + expect(spyOnSuccess).toHaveBeenCalled(); + expect(spyOnError).not.toHaveBeenCalledWith(conflictError); + }); + })); + + it('should try to move children nodes of a folder to already existing folder with same name', async(() => { + const parentFolderToMove = new TestNode('parent-folder', !isFile, 'conflicting-name'); + spyOnDocumentListServiceAction = spyOn(documentListService, 'moveNode').and.callFake( + (contentEntryId, selectionId) => { + if (contentEntryId === parentFolderToMove.entry.id) { + return Observable.throw(conflictError); + + } + return Observable.of({}); + }); + spyOn(service, 'moveContentAction').and.returnValue(Observable.of({})); + + const newDestination = new TestNode('new-destination', !isFile, 'conflicting-name'); + spyOn(service, 'getChildByName').and.returnValue(Observable.of(newDestination)); + const childrenNodes = [ fileToMove, folderToMove ]; + spyOn(service, 'getNodeChildren').and.returnValue(Observable.of( {list: {entries: childrenNodes}} )); + + const moveFolderActionObservable = service.moveFolderAction(parentFolderToMove.entry, folderDestinationId); + moveFolderActionObservable.toPromise() + .then( + () => { + spyOnSuccess(); + }, + (error) => { + spyOnError(error); + }) + .then(() => { + expect(spyOnDocumentListServiceAction).toHaveBeenCalled(); + + expect(spyOnSuccess).toHaveBeenCalled(); + expect(spyOnError).not.toHaveBeenCalledWith(conflictError); + }); + })); + + }); + + describe('moveNodeAction', () => { + describe('on moving folder to a destination where a folder with the same name exists', () => { + let parentFolderToMove; + let moveNodeActionPromise; + let spyOnDelete; + + beforeEach(() => { + parentFolderToMove = new TestNode('parent-folder', !isFile, 'conflicting-name'); + spyOnDelete = spyOn(nodesApiService, 'deleteNode').and.returnValue(Observable.of(null)); + }); + + afterEach(() => { + spyOnDelete.calls.reset(); + spyOnSuccess.calls.reset(); + spyOnError.calls.reset(); + }); + + it('should take no extra delete action, if folder was moved to the same location', async(() => { + spyOn(service, 'moveFolderAction').and.returnValue(Observable.of(null)); + + parentFolderToMove.entry.parentId = folderDestinationId; + moveNodeActionPromise = service.moveNodeAction(parentFolderToMove.entry, folderDestinationId).toPromise(); + moveNodeActionPromise + .then( + () => { + spyOnSuccess(); + }, + (error) => { + spyOnError(error); + }) + .then(() => { + expect(spyOnDelete).not.toHaveBeenCalled(); + + expect(spyOnSuccess).toHaveBeenCalled(); + expect(spyOnError).not.toHaveBeenCalled(); + }); + })); + + it('should take no extra delete action, if its children were partially moved', async(() => { + const movedChildrenNodes = [ fileToMove, folderToMove ]; + spyOn(service, 'moveFolderAction').and.returnValue(Observable.of(movedChildrenNodes)); + spyOn(service, 'processResponse').and.returnValue({ + succeeded: [ fileToMove ], + failed: [ folderToMove ], + partiallySucceeded: [] + }); + + parentFolderToMove.entry.parentId = `not-${folderDestinationId}`; + moveNodeActionPromise = service.moveNodeAction(parentFolderToMove.entry, folderDestinationId).toPromise(); + moveNodeActionPromise + .then( + () => { + spyOnSuccess(); + }, + (error) => { + spyOnError(error); + }) + .then(() => { + expect(spyOnDelete).not.toHaveBeenCalled(); + + expect(spyOnSuccess).toHaveBeenCalled(); + expect(spyOnError).not.toHaveBeenCalled(); + }); + })); + + it('should take extra delete action, if all children nodes were successfully moved and folder is still on location', async(() => { + const movedChildrenNodes = [ fileToMove, folderToMove ]; + spyOn(service, 'moveFolderAction').and.returnValue(Observable.of(movedChildrenNodes)); + spyOn(service, 'processResponse').and.returnValue({ + succeeded: [ movedChildrenNodes ], + failed: [], + partiallySucceeded: [] + }); + const folderOnLocation = parentFolderToMove; + spyOn(service, 'getChildByName').and.returnValue(Observable.of(folderOnLocation)); + + parentFolderToMove.entry.parentId = `not-${folderDestinationId}`; + moveNodeActionPromise = service.moveNodeAction(parentFolderToMove.entry, folderDestinationId).toPromise(); + moveNodeActionPromise + .then( + () => { + spyOnSuccess(); + }, + (error) => { + spyOnError(error); + }) + .then(() => { + expect(spyOnDelete).toHaveBeenCalled(); + + expect(spyOnSuccess).toHaveBeenCalled(); + expect(spyOnError).not.toHaveBeenCalled(); + }); + })); + + it('should take no extra delete action, if folder is no longer on location', async(() => { + const movedChildrenNodes = [ fileToMove, folderToMove ]; + spyOn(service, 'moveFolderAction').and.returnValue(Observable.of(movedChildrenNodes)); + spyOn(service, 'processResponse').and.returnValue({ + succeeded: [ movedChildrenNodes ], + failed: [], + partiallySucceeded: [] + }); + spyOn(service, 'getChildByName').and.returnValue(Observable.of(null)); + + parentFolderToMove.entry.parentId = `not-${folderDestinationId}`; + moveNodeActionPromise = service.moveNodeAction(parentFolderToMove.entry, folderDestinationId).toPromise(); + moveNodeActionPromise + .then( + () => { + spyOnSuccess(); + }, + (error) => { + spyOnError(error); + }) + .then(() => { + expect(spyOnDelete).not.toHaveBeenCalled(); + + expect(spyOnSuccess).toHaveBeenCalled(); + expect(spyOnError).not.toHaveBeenCalled(); + }); + + })); + }); + }); + + }); + + describe('getChildByName', () => { + let testFamilyNodes; + let notChildNode; + let childNode; + + beforeEach(() => { + childNode = new TestNode(fileId, isFile, 'child-name'); + let parentNode = new TestNode(); + + notChildNode = new TestNode('not-child-id', !isFile, 'not-child-name'); + testFamilyNodes = [ + { + parentNodeId: parentNode.entry.id, + nodeChildren: [childNode] + }, { + parentNodeId: notChildNode.entry.id, + nodeChildren: [] + } + ]; + }); + + it('emits child node with specified name, when it exists in folder', () => { + spyOn(nodesApi, 'getNodeChildren').and.callFake(helper.fakeGetNodeChildren(testFamilyNodes)); + + service.getChildByName(testFamilyNodes[0].parentNodeId, childNode.entry.name) + .subscribe( + (value) => { + expect(value).toEqual(childNode); + } + ); + }); + + it('emits null value when child with specified name is not found in folder', async(() => { + spyOn(nodesApi, 'getNodeChildren').and.callFake(helper.fakeGetNodeChildren(testFamilyNodes)); + + service.getChildByName(testFamilyNodes[0].parentNodeId, notChildNode.entry.name) + .subscribe( + (value) => { + expect(value).toEqual(null); + } + ); + })); + + it('emits error when permission error occurs', async(() => { + spyOn(nodesApi, 'getNodeChildren').and.callFake(helper.fakeGetNodeChildren(testFamilyNodes, actionIsForbidden)); + + service.getChildByName(testFamilyNodes[0].parentNodeId, notChildNode.entry.name) + .subscribe( + (value) => { + expect(value).toEqual(null); + } + ); + })); + }); + + describe('getNewNameFrom', () => { + let testData = [ + { + name: 'noExtension', + baseName: 'noExtension', + expected: 'noExtension-1' + }, { + name: 'withExtension.txt', + baseName: 'withExtension.txt', + expected: 'withExtension-1.txt' + }, { + name: 'with-lineStringSufix.txt', + baseName: 'with-lineStringSufix.txt', + expected: 'with-lineStringSufix-1.txt' + }, { + name: 'noExtension-1', + baseName: 'noExtension-1', + expected: 'noExtension-1-1' + }, { + name: 'with-lineNumberSufix-1.txt', + baseName: 'with-lineNumberSufix-1.txt', + expected: 'with-lineNumberSufix-1-1.txt' + }, { + name: 'with-lineNumberSufix.txt', + baseName: undefined, + expected: 'with-lineNumberSufix-1.txt' + }, { + name: 'noExtension-1', + baseName: 'noExtension', + expected: 'noExtension-2' + }, { + name: 'noExtension-7', + baseName: undefined, + expected: 'noExtension-8' + }, { + name: 'noExtension-007', + baseName: undefined, + expected: 'noExtension-007-1' + } + ]; + testData.forEach((data) => { + it(`new name should be \'${data.expected}\' for given name: \'${data.name}\', and baseName: \'${data.baseName}\'`, () => { + const result = service.getNewNameFrom(data.name, data.baseName); + expect(result).toBe(data.expected); + }); + + }); + }); + + describe('flatten', () => { + const testNode1 = new TestNode('node1-id', isFile, 'node1-name'); + const testNode2 = new TestNode('node2-id', !isFile, 'node2-name'); + + let testData = [ + { + nDimArray: [ testNode1 ], + expected: [ testNode1 ] + }, + { + nDimArray: [ [ testNode1 ], [ testNode2 ] ], + expected: [ testNode1, testNode2 ] + }, + { + nDimArray: [ [ [ [ testNode1 ] ], testNode2 ] ], + expected: [ testNode2, testNode1 ] + } + ]; + + testData.forEach((data) => { + it(`flattened array should be \'${data.expected}\' for given data: \'${data.nDimArray}\'`, () => { + const result = service.flatten(data.nDimArray); + + expect(result.length).toBe(data.expected.length); + expect(JSON.stringify(result)).toEqual(JSON.stringify(data.expected)); + }); + + }); + }); + + describe('processResponse', () => { + const testNode1 = new TestNode('node1-id', isFile, 'node1-name'); + const testNode2 = new TestNode('node2-id', !isFile, 'node2-name'); + + const parentID = 'patent-1-id'; + testNode1.entry['parentId'] = parentID; + + let testData = [ + { + data: [ testNode1 ], + expected: { + succeeded: [ testNode1 ], + failed: [], + partiallySucceeded: [] + } + }, { + data: [ [ {itemMoved: testNode1, initialParentId: parentID} ] ], + expected: { + succeeded: [ [ {itemMoved: testNode1, initialParentId: parentID} ] ], + failed: [], + partiallySucceeded: [] + } + }, { + data: [ conflictError ], + expected: { + succeeded: [], + failed: [ conflictError ], + partiallySucceeded: [] + } + }, { + data: [ conflictError, testNode2 ], + expected: { + succeeded: [ testNode2 ], + failed: [ conflictError ], + partiallySucceeded: [] + } + }, { + data: [ conflictError, [ testNode2, conflictError] ], + expected: { + succeeded: [], + failed: [ conflictError ], + partiallySucceeded: [ [ testNode2, conflictError ] ] + } + }, { + data: [ conflictError, [ {}, conflictError] ], + expected: { + succeeded: [], + failed: [ conflictError, [ {}, conflictError ] ], + partiallySucceeded: [] + } + }, { + data: {}, + expected: { + succeeded: [], + failed: [ {} ], + partiallySucceeded: [] + } + }, { + data: testNode1, + expected: { + succeeded: [ testNode1 ], + failed: [], + partiallySucceeded: [] + } + }, { + data: {itemMoved: testNode1, initialParentId: parentID}, + expected: { + succeeded: [ {itemMoved: testNode1, initialParentId: parentID} ], + failed: [], + partiallySucceeded: [] + } + } + ]; + + testData.forEach((response) => { + it(`processed response should be \'${response.expected}\' for given input: \'${response.data}\'`, () => { + const result = service.processResponse(response.data); + + expect(JSON.stringify(result)).toEqual(JSON.stringify(response.expected)); + }); + + }); + }); + +}); diff --git a/src/app/common/services/node-actions.service.ts b/src/app/common/services/node-actions.service.ts new file mode 100644 index 000000000..46d77fec5 --- /dev/null +++ b/src/app/common/services/node-actions.service.ts @@ -0,0 +1,580 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EventEmitter, Injectable } from '@angular/core'; +import { MdDialog } from '@angular/material'; +import { Observable, Subject } from 'rxjs/Rx'; + +import { AlfrescoApiService, AlfrescoContentService, NodesApiService } from 'ng2-alfresco-core'; +import { DataColumn } from 'ng2-alfresco-datatable'; +import { DocumentListService, ContentNodeSelectorComponent, ContentNodeSelectorComponentData, ShareDataRow } from 'ng2-alfresco-documentlist'; +import { MinimalNodeEntity, MinimalNodeEntryEntity } from 'alfresco-js-api'; + +@Injectable() +export class NodeActionsService { + static SNACK_MESSAGE_DURATION_WITH_UNDO: number = 10000; + static SNACK_MESSAGE_DURATION: number = 3000; + + contentCopied: Subject = new Subject(); + contentMoved: Subject = new Subject(); + moveDeletedEntries: any[] = []; + + constructor(private contentService: AlfrescoContentService, + private dialog: MdDialog, + private documentListService: DocumentListService, + private apiService: AlfrescoApiService, + private nodesApi: NodesApiService) {} + + /** + * Copy node list + * + * @param contentEntities nodes to copy + * @param permission permission which is needed to apply the action + */ + public copyNodes(contentEntities: any[], permission?: string): Subject { + return this.doBatchOperation('copy', contentEntities, permission); + } + + /** + * Move node list + * + * @param contentEntities nodes to move + * @param permission permission which is needed to apply the action + */ + public moveNodes(contentEntities: any[], permission?: string): Subject { + return this.doBatchOperation('move', contentEntities, permission); + } + + /** + * General method for performing the given operation (copy|move) to multiple nodes + * + * @param action the action to perform (copy|move) + * @param contentEntities the contentEntities which have to have the action performed on + * @param permission permission which is needed to apply the action + */ + private doBatchOperation(action: string, contentEntities: any[], permission?: string): Subject { + const observable: Subject = new Subject(); + + if (!this.isEntryEntitiesArray(contentEntities)) { + observable.error(new Error(JSON.stringify({error: {statusCode: 400}}))); + + } else if (this.checkPermission(action, contentEntities, permission)) { + + const destinationSelection = this.getContentNodeSelection(action, contentEntities); + destinationSelection.subscribe((selections: MinimalNodeEntryEntity[]) => { + + const contentEntry = contentEntities[0].entry ; + // Check if there's nodeId for Shared Files + const contentEntryId = contentEntry.nodeId || contentEntry.id; + const type = contentEntry.isFolder ? 'folder' : 'content'; + const batch = []; + + // consider only first item in the selection + const selection = selections[0]; + let action$: Observable; + + if (action === 'move' && contentEntities.length === 1 && type === 'content') { + action$ = this.documentListService[`${action}Node`].call(this.documentListService, contentEntryId, selection.id); + action$ = action$.toArray(); + + } else { + contentEntities.forEach((node) => { + batch.push(this[`${action}NodeAction`](node.entry, selection.id)); + }); + action$ = Observable.zip(...batch); + } + + action$ + .subscribe( + (newContent) => { + observable.next(`OPERATION.SUCCES.${type.toUpperCase()}.${action.toUpperCase()}`); + + if (action === 'copy') { + this.contentCopied.next(newContent); + + } else if (action === 'move') { + const processedData = this.processResponse(newContent); + this.contentMoved.next(processedData); + } + + }, + observable.error.bind(observable) + ); + this.dialog.closeAll(); + }); + + } else { + observable.error(new Error(JSON.stringify({error: {statusCode: 403}}))); + } + + return observable; + } + + isEntryEntitiesArray(contentEntities: any[]): boolean { + if (contentEntities && contentEntities.length) { + const nonEntryNode = contentEntities.find(node => (!node || !node.entry || !(node.entry.nodeId || node.entry.id))); + return !nonEntryNode; + } + return false; + } + + checkPermission(action: string, contentEntities: any[], permission?: string) { + const notAllowedNode = contentEntities.find(node => !this.isActionAllowed(action, node.entry, permission)); + return !notAllowedNode; + } + + getFirstParentId(nodeEntities: any[]): string { + for (let i = 0; i < nodeEntities.length; i++) { + const nodeEntry = nodeEntities[i].entry; + + if (nodeEntry.parentId) { + return nodeEntry.parentId; + + } else if (nodeEntry.path && nodeEntry.path.elements && nodeEntry.path.elements.length) { + return nodeEntry.path.elements[nodeEntry.path.elements.length - 1].id; + } + } + + // if no parent data is found, return the id of first item / the nodeId in case of Shared Files + return nodeEntities[0].entry.nodeId || nodeEntities[0].entry.id; + } + + getEntryParentId(nodeEntry: any) { + let entryParentId = ''; + + if (nodeEntry.parentId) { + entryParentId = nodeEntry.parentId; + + } else if (nodeEntry.path && nodeEntry.path.elements && nodeEntry.path.elements.length) { + entryParentId = nodeEntry.path.elements[nodeEntry.path.elements.length - 1].id; + } + + return entryParentId; + } + + getContentNodeSelection(action: string, contentEntities: MinimalNodeEntity[]): EventEmitter { + const currentParentFolderId = this.getFirstParentId(contentEntities); + + let nodeEntryName = ''; + if (contentEntities.length === 1 && contentEntities[0].entry.name) { + nodeEntryName = `${contentEntities[0].entry.name} `; + } + + const data: ContentNodeSelectorComponentData = { + title: `${action} ${nodeEntryName}to ...`, + currentFolderId: currentParentFolderId, + rowFilter: this.rowFilter.bind(this), + imageResolver: this.imageResolver.bind(this), + select: new EventEmitter() + }; + + this.dialog.open(ContentNodeSelectorComponent, { + data, + panelClass: 'adf-content-node-selector-dialog', + width: '630px' + }); + + return data.select; + } + + copyNodeAction(nodeEntry, selectionId): Observable { + if (nodeEntry.isFolder) { + return this.copyFolderAction(nodeEntry, selectionId); + + } else { + // any other type is treated as 'content' + return this.copyContentAction(nodeEntry, selectionId); + } + } + + copyContentAction(contentEntry, selectionId, oldName?): Observable { + const _oldName = oldName || contentEntry.name; + // Check if there's nodeId for Shared Files + const contentEntryId = contentEntry.nodeId || contentEntry.id; + + // use local method until new name parameter is added on ADF copyNode + return this.copyNode(contentEntryId, selectionId, _oldName) + .catch((err) => { + let errStatusCode; + try { + const {error: {statusCode}} = JSON.parse(err.message); + errStatusCode = statusCode; + } catch (e) { // + } + + if (errStatusCode && errStatusCode === 409) { + return this.copyContentAction(contentEntry, selectionId, this.getNewNameFrom(_oldName, contentEntry.name)); + } else { + return Observable.throw(err || 'Server error'); + } + }); + } + + copyFolderAction(contentEntry, selectionId): Observable { + // Check if there's nodeId for Shared Files + const contentEntryId = contentEntry.nodeId || contentEntry.id; + let $destinationFolder: Observable; + let $childrenToCopy: Observable; + let newDestinationFolder; + + return this.copyNode(contentEntryId, selectionId, contentEntry.name) + .catch((err) => { + let errStatusCode; + try { + const {error: {statusCode}} = JSON.parse(err.message); + errStatusCode = statusCode; + } catch (e) { // + } + + if (errStatusCode && errStatusCode === 409) { + + $destinationFolder = this.getChildByName(selectionId, contentEntry.name); + $childrenToCopy = this.getNodeChildren(contentEntryId); + + return $destinationFolder + .flatMap((destination) => { + newDestinationFolder = destination; + return $childrenToCopy; + }) + .flatMap((nodesToCopy) => { + const batch = []; + nodesToCopy.list.entries.forEach((node) => { + if (node.entry.isFolder) { + batch.push(this.copyFolderAction(node.entry, newDestinationFolder.entry.id)); + + } else { + batch.push(this.copyContentAction(node.entry, newDestinationFolder.entry.id)); + } + }); + + if (!batch.length) { + return Observable.of({}); + } + return Observable.zip(...batch); + }); + + } else { + return Observable.throw(err || 'Server error'); + } + }); + } + + moveNodeAction(nodeEntry, selectionId): Observable { + this.moveDeletedEntries = []; + + if (nodeEntry.isFolder) { + const initialParentId = nodeEntry.parentId; + + return this.moveFolderAction(nodeEntry, selectionId) + .flatMap((newContent) => { + + // take no extra action, if folder is moved to the same location + if (initialParentId === selectionId) { + return Observable.of(newContent); + } + + const flattenResponse = this.flatten(newContent); + const processedData = this.processResponse(flattenResponse); + + // else, check if moving this nodeEntry succeeded for ALL of its nodes + if (processedData.failed.length === 0) { + + // check if folder still exists on location + return this.getChildByName(initialParentId, nodeEntry.name) + .flatMap((folderOnInitialLocation) => { + + if (folderOnInitialLocation) { + // Check if there's nodeId for Shared Files + const nodeEntryId = nodeEntry.nodeId || nodeEntry.id; + // delete it from location + return this.nodesApi.deleteNode(nodeEntryId) + .flatMap(() => { + this.moveDeletedEntries.push(nodeEntry); + return Observable.of(newContent); + }); + } + return Observable.of(newContent); + }); + + } + return Observable.of(newContent); + }); + + } else { + // any other type is treated as 'content' + return this.moveContentAction(nodeEntry, selectionId); + } + } + + moveFolderAction(contentEntry, selectionId): Observable { + // Check if there's nodeId for Shared Files + const contentEntryId = contentEntry.nodeId || contentEntry.id; + const initialParentId = this.getEntryParentId(contentEntry); + let $destinationFolder: Observable; + let $childrenToMove: Observable; + let newDestinationFolder; + + return this.documentListService.moveNode(contentEntryId, selectionId) + .map((itemMoved) => { + return { itemMoved, initialParentId }; + }) + .catch((err) => { + let errStatusCode; + try { + const {error: {statusCode}} = JSON.parse(err.message); + errStatusCode = statusCode; + } catch (e) { // + } + + if (errStatusCode && errStatusCode === 409) { + + $destinationFolder = this.getChildByName(selectionId, contentEntry.name); + $childrenToMove = this.getNodeChildren(contentEntryId); + + return $destinationFolder + .flatMap((destination) => { + newDestinationFolder = destination; + return $childrenToMove; + }) + .flatMap((childrenToMove) => { + const batch = []; + childrenToMove.list.entries.forEach((node) => { + if (node.entry.isFolder) { + batch.push(this.moveFolderAction(node.entry, newDestinationFolder.entry.id)); + + } else { + batch.push(this.moveContentAction(node.entry, newDestinationFolder.entry.id)); + } + }); + + if (!batch.length) { + return Observable.of(batch); + } + return Observable.zip(...batch); + }); + } else { + return Observable.throw(err); + } + }); + } + + moveContentAction(contentEntry, selectionId) { + // Check if there's nodeId for Shared Files + const contentEntryId = contentEntry.nodeId || contentEntry.id; + const initialParentId = this.getEntryParentId(contentEntry); + + return this.documentListService.moveNode(contentEntryId, selectionId) + .map((itemMoved) => { + return { itemMoved, initialParentId }; + }) + .catch((err) => { + let errStatusCode; + try { + const {error: {statusCode}} = JSON.parse(err.message); + errStatusCode = statusCode; + } catch (e) { // + } + + if (errStatusCode && errStatusCode === 409) { + // do not throw error, to be able to show message in case of partial move of files + return Observable.of(err); + } else { + return Observable.throw(err); + } + }); + } + + getChildByName(parentId, name) { + let matchedNodes: Subject = new Subject(); + + this.getNodeChildren(parentId).subscribe( + (childrenNodes) => { + const result = childrenNodes.list.entries.find(node => (node.entry.name === name)); + + if (result) { + matchedNodes.next(result); + + } else { + matchedNodes.next(null); + } + }, + (err) => { + return Observable.throw(err || 'Server error'); + }); + return matchedNodes; + } + + private isActionAllowed(action: string, node: MinimalNodeEntryEntity, permission?: string): boolean { + if (action === 'copy') { + return true; + } + return this.contentService.hasPermission(node, permission); + } + + private rowFilter(row: ShareDataRow): boolean { + const node: MinimalNodeEntryEntity = row.node.entry; + return (!node.isFile); + } + + private imageResolver(row: ShareDataRow, col: DataColumn): string | null { + const entry: MinimalNodeEntryEntity = row.node.entry; + if (!this.contentService.hasPermission(entry, 'update')) { + return this.documentListService.getMimeTypeIcon('disable/folder'); + } + + return null; + } + + public getNewNameFrom(name: string, baseName?: string) { + const extensionMatch = name.match(/\.[^/.]+$/); + + // remove extension in case there is one + let fileExtension = extensionMatch ? extensionMatch[0] : ''; + let extensionFree = extensionMatch ? name.slice(0, extensionMatch.index) : name; + + let prefixNumber = 1; + let baseExtensionFree; + + if (baseName) { + const baseExtensionMatch = baseName.match(/\.[^/.]+$/); + + // remove extension in case there is one + baseExtensionFree = baseExtensionMatch ? baseName.slice(0, baseExtensionMatch.index) : baseName; + } + + if (!baseExtensionFree || baseExtensionFree !== extensionFree) { + + // check if name already has integer appended on end: + const oldPrefix = extensionFree.match('-[0-9]+$'); + if (oldPrefix) { + + // if so, try to get the number at the end + const oldPrefixNumber = parseInt(oldPrefix[0].slice(1), 10); + if (oldPrefixNumber.toString() === oldPrefix[0].slice(1)) { + + extensionFree = extensionFree.slice(0, oldPrefix.index); + prefixNumber = oldPrefixNumber + 1; + } + + } + + } + return extensionFree + '-' + prefixNumber + fileExtension; + } + + /** + * Get children nodes of given parent node + * + * @param nodeId The id of the parent node + * @param params optional parameters + */ + getNodeChildren(nodeId: string, params?) { + return Observable.fromPromise(this.apiService.getInstance().nodes.getNodeChildren(nodeId, params)); + } + + // Copied from ADF document-list.service, and added the name parameter + /** + * Copy a node to destination node + * + * @param nodeId The id of the node to be copied + * @param targetParentId The id of the folder-node where the node have to be copied to + * @param name The new name for the copy that would be added on the destination folder + */ + copyNode(nodeId: string, targetParentId: string, name?: string) { + return Observable.fromPromise(this.apiService.getInstance().nodes.copyNode(nodeId, {targetParentId, name})); + } + + public flatten(nDimArray) { + if (!Array.isArray(nDimArray)) { + return nDimArray; + } + + let nodeQueue = nDimArray.slice(0); + let resultingArray = []; + + do { + nodeQueue.forEach( + (node) => { + if (Array.isArray(node)) { + nodeQueue.push(...node); + } else { + resultingArray.push(node); + } + + const nodeIndex = nodeQueue.indexOf(node); + nodeQueue.splice(nodeIndex, 1); + } + ); + } while (nodeQueue.length); + + return resultingArray; + } + + processResponse(data: any): any { + const moveStatus = { + succeeded: [], + failed: [], + partiallySucceeded: [] + }; + + if (Array.isArray(data)) { + return data.reduce( + (acc, next) => { + + if (next instanceof Error) { + acc.failed.push(next); + + } else if (Array.isArray(next)) { + // if content of a folder was moved + + const folderMoveResponseData = this.flatten(next); + const foundError = folderMoveResponseData.find(node => node instanceof Error); + // data might contain also items of form: { itemMoved, initialParentId } + const foundEntry = folderMoveResponseData.find(node => (node.itemMoved && node.itemMoved.entry) || (node && node.entry)); + + if (!foundError) { + // consider success if NONE of the items from the folder move response is an error + acc.succeeded.push(next); + + } else if (!foundEntry) { + // consider failed if NONE of the items has an entry + acc.failed.push(next); + + } else { + // partially move folder + acc.partiallySucceeded.push(next); + } + + } else { + acc.succeeded.push(next); + } + + return acc; + }, + moveStatus + ); + } else { + if ((data.itemMoved && data.itemMoved.entry) || (data && data.entry)) { + moveStatus.succeeded.push(data); + } else { + moveStatus.failed.push(data); + } + + return moveStatus; + } + } +} diff --git a/src/app/components/current-user/current-user.component.html b/src/app/components/current-user/current-user.component.html new file mode 100644 index 000000000..852185f86 --- /dev/null +++ b/src/app/components/current-user/current-user.component.html @@ -0,0 +1,18 @@ +
+
+ + {{ userName }} + + {{ userInitials }} + + +
+ + + + +
diff --git a/src/app/components/current-user/current-user.component.scss b/src/app/components/current-user/current-user.component.scss new file mode 100644 index 000000000..18a48aea1 --- /dev/null +++ b/src/app/components/current-user/current-user.component.scss @@ -0,0 +1,36 @@ +@import './../../ui/variables'; + +$am-avatar-size: 40px; + +$am-avatar-light-bg: rgba(white, .15); +$am-avatar-dark-bg: rgba(black, .15); + +:host { + font-weight: lighter; + position: relative; + + color: $alfresco-white; + line-height: 20px; + + .am-avatar { + margin-left: 5px; + cursor: pointer; + + display: inline-block; + width: $am-avatar-size; + height: $am-avatar-size; + line-height: $am-avatar-size; + font-size: 1.2em; + text-align: center; + color: inherit; + border-radius: 100%; + + &--light { + background: $am-avatar-light-bg; + } + + &--dark { + background: $am-avatar-dark-bg; + } + } +} diff --git a/src/app/components/current-user/current-user.component.spec.ts b/src/app/components/current-user/current-user.component.spec.ts new file mode 100644 index 000000000..7ca5239a4 --- /dev/null +++ b/src/app/components/current-user/current-user.component.spec.ts @@ -0,0 +1,66 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed, async } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Observable } from 'rxjs/Rx'; +import { MaterialModule } from '@angular/material'; +import { CoreModule, PeopleContentService } from 'ng2-alfresco-core'; + +import { CurrentUserComponent } from './current-user.component'; + +describe('CurrentUserComponent', () => { + let fixture; + let component; + let peopleApi: PeopleContentService; + let user; + + beforeEach(() => { + user = { entry: { firstName: 'joe', lastName: 'doe' } }; + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + MaterialModule, + CoreModule, + RouterTestingModule + ], + declarations: [ + CurrentUserComponent + ] + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(CurrentUserComponent); + component = fixture.componentInstance; + peopleApi = TestBed.get(PeopleContentService); + + spyOn(peopleApi, 'getCurrentPerson').and.returnValue(Observable.of(user)); + + fixture.detectChanges(); + }); + })); + + it('updates user data', () => { + expect(component.user).toBe(user.entry); + }); + + it('get user initials', () => { + expect(component.userInitials).toBe('jd'); + }); +}); diff --git a/src/app/components/current-user/current-user.component.ts b/src/app/components/current-user/current-user.component.ts new file mode 100644 index 000000000..39904320e --- /dev/null +++ b/src/app/components/current-user/current-user.component.ts @@ -0,0 +1,63 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { PeopleContentService } from 'ng2-alfresco-core'; +import { Subscription } from 'rxjs/Rx'; + +@Component({ + selector: 'app-current-user', + templateUrl: './current-user.component.html', + styleUrls: [ './current-user.component.scss' ] +}) +export class CurrentUserComponent implements OnInit, OnDestroy { + private user: any = null; + private personSubscription: Subscription; + + constructor(private peopleApi: PeopleContentService) {} + + ngOnInit() { + this.personSubscription = this.peopleApi.getCurrentPerson() + .subscribe((person: any) => { + this.user = person.entry; + }); + } + + ngOnDestroy() { + this.personSubscription.unsubscribe(); + } + + get userFirstName(): string { + const { user } = this; + return user ? (user.firstName || '') : ''; + } + + get userLastName(): string { + const { user } = this; + return user ? (user.lastName || '') : ''; + } + + get userName(): string { + const { userFirstName: first, userLastName: last } = this; + return `${first} ${last}`; + } + + get userInitials(): string { + const { userFirstName: first, userLastName: last } = this; + return [ first[0], last[0] ].join(''); + } +} diff --git a/src/app/components/favorites/favorites.component.html b/src/app/components/favorites/favorites.component.html new file mode 100644 index 000000000..bfa1a8880 --- /dev/null +++ b/src/app/components/favorites/favorites.component.html @@ -0,0 +1,135 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + {{ value }} + + + + + + + + + + + + + + + + + +
+
diff --git a/src/app/components/favorites/favorites.component.spec.ts b/src/app/components/favorites/favorites.component.spec.ts new file mode 100644 index 000000000..0728982b9 --- /dev/null +++ b/src/app/components/favorites/favorites.component.spec.ts @@ -0,0 +1,189 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TestBed, async } from '@angular/core/testing'; +import { Observable } from 'rxjs/Rx'; + +import { CoreModule, NodesApiService, AlfrescoApiService } from 'ng2-alfresco-core'; +import { CommonModule } from '../../common/common.module'; + +import { ContentManagementService } from '../../common/services/content-management.service'; + +import { FavoritesComponent } from './favorites.component'; + +describe('Favorites Routed Component', () => { + let fixture; + let component: FavoritesComponent; + let nodesApi: NodesApiService; + let alfrescoApi: AlfrescoApiService; + let contentService: ContentManagementService; + let router: Router; + let page; + let node; + + beforeAll(() => { + // testing only functional-wise not time-wise + Observable.prototype.debounceTime = function () { return this; }; + }); + + beforeEach(() => { + page = { + list: { + entries: [ + { entry: { id: 1, target: { file: {} } } }, + { entry: { id: 2, target: { folder: {} } } } + ], + pagination: { data: 'data'} + } + }; + + node = { + id: 'folder-node', + isFolder: true, + isFile: false, + path: { + elements: [] + } + }; + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CoreModule, + CommonModule, + RouterTestingModule + ], + declarations: [ + FavoritesComponent + ] + }) + .compileComponents().then(() => { + fixture = TestBed.createComponent(FavoritesComponent); + component = fixture.componentInstance; + + nodesApi = TestBed.get(NodesApiService); + alfrescoApi = TestBed.get(AlfrescoApiService); + contentService = TestBed.get(ContentManagementService); + router = TestBed.get(Router); + }); + })); + + beforeEach(() => { + spyOn(alfrescoApi.favoritesApi, 'getFavorites').and.returnValue(Promise.resolve(page)); + }); + + describe('Events', () => { + it('should refresh on editing folder event', () => { + spyOn(component, 'refresh'); + fixture.detectChanges(); + + contentService.editFolder.next(null); + + expect(component.refresh).toHaveBeenCalled(); + }); + + it('should refresh on move node event', () => { + spyOn(component, 'refresh'); + fixture.detectChanges(); + + contentService.moveNode.next(null); + + expect(component.refresh).toHaveBeenCalled(); + }); + + it('should fetch nodes on favorite toggle', () => { + spyOn(component, 'refresh'); + fixture.detectChanges(); + + contentService.toggleFavorite.next(null); + + expect(component.refresh).toHaveBeenCalled(); + }); + }); + + describe('Node navigation', () => { + beforeEach(() => { + spyOn(nodesApi, 'getNode').and.returnValue(Observable.of(node)); + spyOn(router, 'navigate'); + fixture.detectChanges(); + }); + + it('navigates to `/libraries` if node path has `Sites`', () => { + node.path.elements = [{ name: 'Sites' }]; + + component.navigate(node); + + expect(router.navigate).toHaveBeenCalledWith([ '/libraries', 'folder-node' ]); + }); + + it('navigates to `/personal-files` if node path has no `Sites`', () => { + node.path.elements = [{ name: 'something else' }]; + + component.navigate(node); + + expect(router.navigate).toHaveBeenCalledWith([ '/personal-files', 'folder-node' ]); + }); + + it('does not navigate when node is not folder', () => { + node.isFolder = false; + + component.navigate(node); + + expect(router.navigate).not.toHaveBeenCalled(); + }); + }); + + describe('onNodeDoubleClick', () => { + beforeEach(() => { + spyOn(nodesApi, 'getNode').and.returnValue(Observable.of(node)); + fixture.detectChanges(); + }); + + it('navigates if node is a folder', () => { + node.isFolder = true; + spyOn(router, 'navigate'); + + component.onNodeDoubleClick(node); + + expect(router.navigate).toHaveBeenCalled(); + }); + + it('opens preview if node is a file', () => { + node.isFolder = false; + node.isFile = true; + spyOn(router, 'navigate').and.stub(); + + component.onNodeDoubleClick(node); + + expect(router.navigate).toHaveBeenCalledWith(['/preview', node.id]); + }); + }); + + describe('refresh', () => { + it('should call document list reload', () => { + spyOn(component.documentList, 'reload'); + fixture.detectChanges(); + + component.refresh(); + + expect(component.documentList.reload).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/components/favorites/favorites.component.ts b/src/app/components/favorites/favorites.component.ts new file mode 100644 index 000000000..7cda3897d --- /dev/null +++ b/src/app/components/favorites/favorites.component.ts @@ -0,0 +1,100 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, ViewChild, OnInit, OnDestroy } from '@angular/core'; +import { Router } from '@angular/router'; +import { Subscription } from 'rxjs/Rx'; + +import { MinimalNodeEntryEntity, PathElementEntity, PathInfoEntity } from 'alfresco-js-api'; +import { NodesApiService } from 'ng2-alfresco-core'; +import { DocumentListComponent } from 'ng2-alfresco-documentlist'; + +import { ContentManagementService } from '../../common/services/content-management.service'; +import { PageComponent } from '../page.component'; + +@Component({ + templateUrl: './favorites.component.html' +}) +export class FavoritesComponent extends PageComponent implements OnInit, OnDestroy { + + @ViewChild(DocumentListComponent) + documentList: DocumentListComponent; + + private onEditFolder: Subscription; + private onMoveNode: Subscription; + private onToggleFavorite: Subscription; + + constructor( + private router: Router, + private nodesApi: NodesApiService, + private content: ContentManagementService) { + super(); + } + + ngOnInit() { + this.onEditFolder = this.content.editFolder.subscribe(() => this.refresh()); + this.onMoveNode = this.content.moveNode.subscribe(() => this.refresh()); + this.onToggleFavorite = this.content.toggleFavorite + .debounceTime(300).subscribe(() => this.refresh()); + } + + ngOnDestroy() { + this.onEditFolder.unsubscribe(); + this.onMoveNode.unsubscribe(); + this.onToggleFavorite.unsubscribe(); + } + + fetchNodes(): void { + // todo: remove once all views migrate to native data source + } + + navigate(favorite: MinimalNodeEntryEntity) { + const { isFolder, id } = favorite; + + // TODO: rework as it will fail on non-English setups + const isSitePath = (path: PathInfoEntity): boolean => { + return path.elements.some(({ name }: PathElementEntity) => (name === 'Sites')); + }; + + if (isFolder) { + this.nodesApi + .getNode(id) + .subscribe(({ path }: MinimalNodeEntryEntity) => { + const routeUrl = isSitePath(path) ? '/libraries' : '/personal-files'; + this.router.navigate([ routeUrl, id ]); + }); + } + } + + onNodeDoubleClick(node: MinimalNodeEntryEntity) { + if (node) { + if (node.isFolder) { + this.navigate(node); + } + + if (node.isFile) { + this.router.navigate(['/preview', node.id]); + } + } + } + + refresh(): void { + if (this.documentList) { + this.documentList.reload(); + } + } +} diff --git a/src/app/components/files/files.component.html b/src/app/components/files/files.component.html new file mode 100644 index 000000000..fcaafd62e --- /dev/null +++ b/src/app/components/files/files.component.html @@ -0,0 +1,149 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + {{ value }} + + + + + + {{ value | adfFileSize }} + + + + + + {{ value | adfTimeAgo }} + + + + + + + + + + + + + +
+
diff --git a/src/app/components/files/files.component.spec.ts b/src/app/components/files/files.component.spec.ts new file mode 100644 index 000000000..e4ad406fe --- /dev/null +++ b/src/app/components/files/files.component.spec.ts @@ -0,0 +1,427 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Observable } from 'rxjs/Rx'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TestBed, async } from '@angular/core/testing'; +import { UploadService, NodesApiService, FileUploadCompleteEvent, + FileUploadDeleteEvent, FileModel, AlfrescoContentService } from 'ng2-alfresco-core'; + +import { CommonModule } from '../../common/common.module'; +import { ContentManagementService } from '../../common/services/content-management.service'; +import { BrowsingFilesService } from '../../common/services/browsing-files.service'; +import { NodeActionsService } from '../../common/services/node-actions.service'; +import { FilesComponent } from './files.component'; + +describe('FilesComponent', () => { + let node; + let page; + let fixture; + let component: FilesComponent; + let contentManagementService: ContentManagementService; + let alfrescoContentService: AlfrescoContentService; + let uploadService: UploadService; + let nodesApi: NodesApiService; + let router: Router; + let browsingFilesService: BrowsingFilesService; + let nodeActionsService: NodeActionsService; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + CommonModule + ], + declarations: [ + FilesComponent + ] + }).compileComponents() + .then(() => { + + fixture = TestBed.createComponent(FilesComponent); + component = fixture.componentInstance; + + contentManagementService = TestBed.get(ContentManagementService); + uploadService = TestBed.get(UploadService); + nodesApi = TestBed.get(NodesApiService); + router = TestBed.get(Router); + alfrescoContentService = TestBed.get(AlfrescoContentService); + browsingFilesService = TestBed.get(BrowsingFilesService); + nodeActionsService = TestBed.get(NodeActionsService); + }); + })); + + beforeEach(() => { + node = { id: 'node-id' }; + page = { + list: { + entries: ['a', 'b', 'c'], + pagination: {} + } + }; + }); + + describe('OnInit', () => { + it('set current node', () => { + spyOn(component, 'fetchNode').and.returnValue(Observable.of(node)); + spyOn(component, 'fetchNodes').and.returnValue(Observable.of(page)); + + fixture.detectChanges(); + + expect(component.node).toBe(node); + }); + + it('get current node children', () => { + spyOn(component, 'fetchNode').and.returnValue(Observable.of(node)); + spyOn(component, 'fetchNodes').and.returnValue(Observable.of(page)); + + fixture.detectChanges(); + + expect(component.paging).toBe(page); + }); + + it('emits onChangeParent event', () => { + spyOn(component, 'fetchNode').and.returnValue(Observable.of(node)); + spyOn(component, 'fetchNodes').and.returnValue(Observable.of(page)); + spyOn(browsingFilesService.onChangeParent, 'next').and.callFake((val) => val); + + fixture.detectChanges(); + + expect(browsingFilesService.onChangeParent.next) + .toHaveBeenCalledWith(node); + }); + + it('raise error when fetchNode() fails', () => { + spyOn(component, 'fetchNode').and.returnValue(Observable.throw(null)); + spyOn(component, 'onFetchError'); + + fixture.detectChanges(); + + expect(component.onFetchError).toHaveBeenCalled(); + }); + + it('raise error when fetchNodes() fails', () => { + spyOn(component, 'fetchNode').and.returnValue(Observable.of(node)); + spyOn(component, 'fetchNodes').and.returnValue(Observable.throw(null)); + spyOn(component, 'onFetchError'); + + fixture.detectChanges(); + + expect(component.onFetchError).toHaveBeenCalled(); + }); + }); + + describe('refresh on events', () => { + beforeEach(() => { + spyOn(component, 'fetchNode').and.returnValue(Observable.of(node)); + spyOn(component, 'fetchNodes').and.returnValue(Observable.of(page)); + spyOn(component, 'load'); + + fixture.detectChanges(); + }); + + it('reset favorites colection onToggleFavorite event', () => { + contentManagementService.toggleFavorite.next(null); + + expect(component.load).toHaveBeenCalled(); + }); + + it('calls refresh onContentCopied event if parent is the same', () => { + const nodes = [ + { entry: { parentId: '1' } }, + { entry: { parentId: '2' } } + ]; + + component.node = { id: '1' }; + + nodeActionsService.contentCopied.next(nodes); + + expect(component.load).toHaveBeenCalled(); + }); + + it('does not call refresh onContentCopied event when parent mismatch', () => { + const nodes = [ + { entry: { parentId: '1' } }, + { entry: { parentId: '2' } } + ]; + + component.node = { id: '3' }; + + nodeActionsService.contentCopied.next(nodes); + + expect(component.load).not.toHaveBeenCalled(); + }); + + it('calls refresh onCreateFolder event', () => { + contentManagementService.createFolder.next(); + + expect(component.load).toHaveBeenCalled(); + }); + + it('calls refresh editFolder event', () => { + contentManagementService.editFolder.next(); + + expect(component.load).toHaveBeenCalled(); + }); + + it('calls refresh deleteNode event', () => { + contentManagementService.deleteNode.next(); + + expect(component.load).toHaveBeenCalled(); + }); + + it('calls refresh moveNode event', () => { + contentManagementService.moveNode.next(); + + expect(component.load).toHaveBeenCalled(); + }); + + it('calls refresh restoreNode event', () => { + contentManagementService.restoreNode.next(); + + expect(component.load).toHaveBeenCalled(); + }); + + it('calls refresh on fileUploadComplete event if parent node match', () => { + const file = { file: { options: { parentId: 'parentId' } } }; + component.node = { id: 'parentId' }; + + uploadService.fileUploadComplete.next(file); + + expect(component.load).toHaveBeenCalled(); + }); + + it('does not call refresh on fileUploadComplete event if parent mismatch', () => { + const file = { file: { options: { parentId: 'otherId' } } }; + component.node = { id: 'parentId' }; + + uploadService.fileUploadComplete.next(file); + + expect(component.load).not.toHaveBeenCalled(); + }); + + it('calls refresh on fileUploadDeleted event if parent node match', () => { + const file = { file: { options: { parentId: 'parentId' } } }; + component.node = { id: 'parentId' }; + + uploadService.fileUploadDeleted.next(file); + + expect(component.load).toHaveBeenCalled(); + }); + + it('does not call refresh on fileUploadDeleted event if parent mismatch', () => { + const file = { file: { options: { parentId: 'otherId' } } }; + component.node = { id: 'parentId' }; + + uploadService.fileUploadDeleted.next(file); + + expect(component.load).not.toHaveBeenCalled(); + }); + }); + + describe('fetchNode()', () => { + beforeEach(() => { + spyOn(component, 'fetchNodes').and.returnValue(Observable.of(page)); + spyOn(nodesApi, 'getNode').and.returnValue(Observable.of(node)); + + fixture.detectChanges(); + }); + + it('calls getNode api with node id', () => { + component.fetchNode('nodeId'); + + expect(nodesApi.getNode).toHaveBeenCalledWith('nodeId'); + }); + }); + + describe('fetchNodes()', () => { + beforeEach(() => { + spyOn(component, 'fetchNode').and.returnValue(Observable.of(node)); + spyOn(nodesApi, 'getNodeChildren').and.returnValue(Observable.of(page)); + + fixture.detectChanges(); + }); + + it('calls getNode api with node id', () => { + component.fetchNodes('nodeId'); + + expect(nodesApi.getNodeChildren).toHaveBeenCalledWith('nodeId', {}); + }); + }); + + describe('Create permission', () => { + beforeEach(() => { + spyOn(component, 'fetchNode').and.returnValue(Observable.of(node)); + spyOn(component, 'fetchNodes').and.returnValue(Observable.of(page)); + + fixture.detectChanges(); + }); + + it('returns false when node is not provided', () => { + expect(component.canCreateContent(null)).toBe(false); + }); + + it('returns false when node does not have permission', () => { + spyOn(alfrescoContentService, 'hasPermission').and.returnValue(false); + + expect(component.canCreateContent(node)).toBe(false); + }); + + it('returns false when node has permission', () => { + spyOn(alfrescoContentService, 'hasPermission').and.returnValue(true); + + expect(component.canCreateContent(node)).toBe(true); + }); + }); + + describe('onNodeDoubleClick()', () => { + beforeEach(() => { + spyOn(component, 'fetchNode').and.returnValue(Observable.of(node)); + spyOn(component, 'fetchNodes').and.returnValue(Observable.of(page)); + + fixture.detectChanges(); + }); + + it('opens preview if node is file', () => { + spyOn(router, 'navigate').and.stub(); + node.isFile = true; + + component.onNodeDoubleClick( node); + + expect(router.navigate).toHaveBeenCalledWith(['/preview', node.id]); + }); + + it('navigate if node is folder', () => { + spyOn(component, 'navigate').and.stub(); + node.isFolder = true; + + component.onNodeDoubleClick( node); + + expect(component.navigate).toHaveBeenCalled(); + }); + }); + + describe('load()', () => { + let fetchNodesSpy; + + beforeEach(() => { + spyOn(component, 'fetchNode').and.returnValue(Observable.of(node)); + fetchNodesSpy = spyOn(component, 'fetchNodes'); + + fetchNodesSpy.and.returnValue(Observable.of(page)); + + fixture.detectChanges(); + }); + + afterEach(() => { + fetchNodesSpy.calls.reset(); + }); + + it('shows load indicator', () => { + spyOn(component, 'onPageLoaded'); + component.node = { id: 'currentNode' }; + + expect(component.isLoading).toBe(false); + + component.load(true); + + expect(component.isLoading).toBe(true); + }); + + it('does not show load indicator', () => { + spyOn(component, 'onPageLoaded'); + component.node = { id: 'currentNode' }; + + expect(component.isLoading).toBe(false); + + component.load(); + + expect(component.isLoading).toBe(false); + }); + + it('sets data on success', () => { + component.node = { id: 'currentNode' }; + + component.load(); + + expect(component.paging).toBe(page); + expect(component.pagination).toBe(page.list.pagination); + }); + + it('raise error on fail', () => { + fetchNodesSpy.and.returnValue(Observable.throw(null)); + spyOn(component, 'onFetchError'); + + component.load(); + + expect(component.onFetchError).toHaveBeenCalled(); + }); + }); + + describe('onBreadcrumbNavigate()', () => { + beforeEach(() => { + spyOn(component, 'fetchNode').and.returnValue(Observable.of(node)); + spyOn(component, 'fetchNodes').and.returnValue(Observable.of(page)); + + fixture.detectChanges(); + }); + + it('navigates to node id', () => { + const routeData = { id: 'some-where-over-the-rainbow' }; + spyOn(component, 'navigate'); + + component.onBreadcrumbNavigate(routeData); + + expect(component.navigate).toHaveBeenCalledWith(routeData.id); + }); + }); + + describe('Node navigation', () => { + beforeEach(() => { + spyOn(component, 'fetchNode').and.returnValue(Observable.of(node)); + spyOn(component, 'fetchNodes').and.returnValue(Observable.of(page)); + spyOn(router, 'navigate'); + + fixture.detectChanges(); + }); + + it('navigates to node when id provided', () => { + component.navigate(node.id); + + expect(router.navigate).toHaveBeenCalledWith(['./', node.id], jasmine.any(Object)); + }); + + it('navigates to home when id not provided', () => { + component.navigate(); + + expect(router.navigate).toHaveBeenCalledWith(['./'], jasmine.any(Object)); + }); + + it('it navigate home if node is root', () => { + (component).node = { + path: { + elements: [ {id: 'node-id'} ] + } + }; + + component.navigate(node.id); + + expect(router.navigate).toHaveBeenCalledWith(['./'], jasmine.any(Object)); + }); + }); +}); diff --git a/src/app/components/files/files.component.ts b/src/app/components/files/files.component.ts new file mode 100644 index 000000000..0ff4f37a0 --- /dev/null +++ b/src/app/components/files/files.component.ts @@ -0,0 +1,191 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Observable, Subscription } from 'rxjs/Rx'; +import { Component, ViewChild, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; +import { Router, ActivatedRoute, Params } from '@angular/router'; +import { MinimalNodeEntity, MinimalNodeEntryEntity, PathElementEntity, NodePaging } from 'alfresco-js-api'; +import { UploadService, FileUploadEvent, NodesApiService, AlfrescoContentService } from 'ng2-alfresco-core'; + +import { BrowsingFilesService } from '../../common/services/browsing-files.service'; +import { ContentManagementService } from '../../common/services/content-management.service'; +import { NodeActionsService } from '../../common/services/node-actions.service'; + +import { PageComponent } from '../page.component'; + +@Component({ + templateUrl: './files.component.html' +}) +export class FilesComponent extends PageComponent implements OnInit, OnDestroy { + private routeData: any = {}; + + private onCopyNode: Subscription; + private onRemoveItem: Subscription; + private onCreateFolder: Subscription; + private onEditFolder: Subscription; + private onDeleteNode: Subscription; + private onMoveNode: Subscription; + private onRestoreNode: Subscription; + private onFileUploadComplete: Subscription; + private onToggleFavorite: Subscription; + + constructor( + private router: Router, + private route: ActivatedRoute, + private nodesApi: NodesApiService, + private changeDetector: ChangeDetectorRef, + private nodeActionsService: NodeActionsService, + private uploadService: UploadService, + private contentManagementService: ContentManagementService, + private browsingFilesService: BrowsingFilesService, + private contentService: AlfrescoContentService) { + super(); + } + + ngOnInit() { + const { route, contentManagementService, nodeActionsService, uploadService } = this; + const { data } = route.snapshot; + + this.routeData = data; + this.title = data.i18nTitle; + + route.params.subscribe(({ id }: Params) => { + const nodeId = id || data.defaultNodeId; + this.isLoading = true; + + this.fetchNode(nodeId) + .do((node) => this.updateCurrentNode(node)) + .flatMap((node) => { + return this.fetchNodes(node.id); + }) + .subscribe( + (page) => this.onPageLoaded(page), + error => this.onFetchError(error) + ); + }); + + this.onCopyNode = nodeActionsService.contentCopied + .subscribe((nodes) => this.onContentCopied(nodes)); + this.onCreateFolder = contentManagementService.createFolder.subscribe(() => this.load()); + this.onEditFolder = contentManagementService.editFolder.subscribe(() => this.load()); + this.onDeleteNode = contentManagementService.deleteNode.subscribe(() => this.load()); + this.onMoveNode = contentManagementService.moveNode.subscribe(() => this.load()); + this.onRestoreNode = contentManagementService.restoreNode.subscribe(() => this.load()); + this.onToggleFavorite = contentManagementService.toggleFavorite.subscribe(() => this.load()); + this.onFileUploadComplete = uploadService.fileUploadComplete + .subscribe(file => this.onFileUploadedEvent(file)); + this.onRemoveItem = uploadService.fileUploadDeleted + .subscribe((file) => this.onFileUploadedEvent(file)); + } + + ngOnDestroy() { + this.onCopyNode.unsubscribe(); + this.onRemoveItem.unsubscribe(); + this.onCreateFolder.unsubscribe(); + this.onEditFolder.unsubscribe(); + this.onDeleteNode.unsubscribe(); + this.onMoveNode.unsubscribe(); + this.onRestoreNode.unsubscribe(); + this.onFileUploadComplete.unsubscribe(); + this.onToggleFavorite.unsubscribe(); + + this.browsingFilesService.onChangeParent.next(null); + } + + fetchNode(nodeId: string): Observable { + return this.nodesApi.getNode(nodeId); + } + + fetchNodes(parentNodeId?: string, options: any = {}): Observable { + return this.nodesApi.getNodeChildren(parentNodeId, options); + } + + navigate(nodeId: string = null) { + const commands = [ './' ]; + + if (nodeId && !this.isRootNode(nodeId)) { + commands.push(nodeId); + } + + this.router.navigate(commands, { + relativeTo: this.route.parent + }); + } + + onNodeDoubleClick(node: MinimalNodeEntryEntity) { + if (node) { + if (node.isFolder) { + this.navigate(node.id); + } + + if (node.isFile) { + this.router.navigate(['/preview', node.id]); + } + } + } + + onBreadcrumbNavigate(route: PathElementEntity) { + this.navigate(route.id); + } + + onFileUploadedEvent(event: FileUploadEvent) { + if (event && event.file.options.parentId === this.getParentNodeId()) { + this.load(); + } + } + + onContentCopied(nodes: MinimalNodeEntity[]) { + const newNode = nodes + .find((node) => { + return node && node.entry && node.entry.parentId === this.getParentNodeId(); + }); + if (newNode) { + this.load(); + } + } + + canCreateContent(parentNode: MinimalNodeEntryEntity): boolean { + if (parentNode) { + return this.contentService.hasPermission(parentNode, 'create'); + } + + return false; + } + + load(showIndicator: boolean = false, pagination: any = {}) { + this.isLoading = showIndicator; + + this.fetchNodes(this.getParentNodeId(), pagination) + .subscribe( + (page) => this.onPageLoaded(page), + error => this.onFetchError(error), + () => this.changeDetector.detectChanges() + ); + } + + private updateCurrentNode(node) { + this.node = node; + this.browsingFilesService.onChangeParent.next(node); + } + + private isRootNode(nodeId: string): boolean { + if (this.node && this.node.path && this.node.path.elements && this.node.path.elements.length > 0) { + return this.node.path.elements[0].id === nodeId; + } + return false; + } +} diff --git a/src/app/components/header/header.component.html b/src/app/components/header/header.component.html new file mode 100644 index 000000000..15af2fe7d --- /dev/null +++ b/src/app/components/header/header.component.html @@ -0,0 +1,15 @@ + + + {{ appTitle }} + + + + + + + + diff --git a/src/app/components/header/header.component.scss b/src/app/components/header/header.component.scss new file mode 100644 index 000000000..6e7c1c419 --- /dev/null +++ b/src/app/components/header/header.component.scss @@ -0,0 +1,57 @@ +@import './../../ui/variables'; + +.app-menu { + + &.adf-toolbar { + .mat-toolbar { + background-color: #00bcd4; + font-family: 'Muli',"Roboto","Helvetica","Arial",sans-serif !important; + min-height: $app-menu-height; + height: $app-menu-height; + + .mat-toolbar-layout { + height: $app-menu-height; + + .mat-toolbar-row { + height: $app-menu-height; + } + } + } + + .adf-toolbar-divider > div { + background-color: $alfresco-white !important; + } + } + + .app-menu__title { + width: 100px; + height: $app-menu-height; + + line-height: 54px; + text-decoration: none; + text-indent: -9999px; + + color: inherit; + + background: url('/assets/images/alfresco-logo-white.svg') no-repeat 0 50%; + background-size: 100% auto; + + display: block; + position: relative; + font-size: 20px; + line-height: 1; + letter-spacing: .02em; + font-weight: 400; + + &:after { + content: "Build #" attr(data-build-number); + color: rgba(white, .66); + font-size: 8px; + position: absolute; + bottom: 8px; + right: 2px; + line-height: 1; + text-indent: 0; + } + } +} diff --git a/src/app/components/header/header.component.spec.ts b/src/app/components/header/header.component.spec.ts new file mode 100644 index 000000000..5b4497e8d --- /dev/null +++ b/src/app/components/header/header.component.spec.ts @@ -0,0 +1,89 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { CoreModule, AppConfigService, PeopleContentService } from 'ng2-alfresco-core'; +import { Observable } from 'rxjs/Rx'; +import { CommonModule } from './../../common/common.module'; + +import { HeaderComponent } from './header.component'; +import { SearchComponent } from '../search/search.component'; +import { CurrentUserComponent } from '../current-user/current-user.component'; + +describe('HeaderComponent', () => { + let fixture; + let component; + let appConfigService: AppConfigService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CoreModule, + RouterTestingModule, + CommonModule + ], + declarations: [ + HeaderComponent, + SearchComponent, + CurrentUserComponent + ] + }) + .overrideProvider(PeopleContentService, { + useValue: { + getCurrentPerson: () => Observable.of({ entry: {} }) + } + }); + + fixture = TestBed.createComponent(HeaderComponent); + component = fixture.componentInstance; + appConfigService = TestBed.get(AppConfigService); + + spyOn(appConfigService, 'get').and.callFake((val) => { + if (val === 'application.name') { + return 'app-name'; + } + + if (val === 'application.build') { + return 'build-nr'; + } + }); + + fixture.detectChanges(); + }); + + it('get application name', () => { + expect(component.appName).toBe('app-name'); + }); + + it('get application build number', () => { + expect(component.appBuildNumber).toBe('build-nr'); + }); + + it('get application title', () => { + expect(component.appTitle).toContain('app-name'); + expect(component.appTitle).toContain('build-nr'); + }); + + it('toggle contrast', () => { + component.enhancedContrast = false; + + component.toggleContrast(); + + expect(component.enhancedContrast).toBe(true); + }); +}); diff --git a/src/app/components/header/header.component.ts b/src/app/components/header/header.component.ts new file mode 100644 index 000000000..5f24938c9 --- /dev/null +++ b/src/app/components/header/header.component.ts @@ -0,0 +1,49 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, ViewEncapsulation } from '@angular/core'; +import { AppConfigService } from 'ng2-alfresco-core'; + +@Component({ + selector: 'app-header', + templateUrl: './header.component.html', + styleUrls: [ './header.component.scss' ], + encapsulation: ViewEncapsulation.None +}) +export class HeaderComponent { + private enhancedContrast: Boolean = false; + + constructor(private appConfig: AppConfigService) {} + + get appName(): string { + return this.appConfig.get('application.name'); + } + + get appBuildNumber(): string { + return this.appConfig.get('application.build'); + } + + get appTitle(): string { + const { appName, appBuildNumber } = this; + + return `${appName} (Build #${appBuildNumber})`; + } + + toggleContrast() { + this.enhancedContrast = !this.enhancedContrast; + } +} diff --git a/src/app/components/layout/layout.component.html b/src/app/components/layout/layout.component.html new file mode 100644 index 000000000..42fb479cf --- /dev/null +++ b/src/app/components/layout/layout.component.html @@ -0,0 +1,15 @@ +
+ + + +
+ + +
+ + +
+
diff --git a/src/app/components/layout/layout.component.scss b/src/app/components/layout/layout.component.scss new file mode 100644 index 000000000..c3aa6941d --- /dev/null +++ b/src/app/components/layout/layout.component.scss @@ -0,0 +1,8 @@ +:host { + display: flex; + flex: 1; + + router-outlet { + flex: 0 0; + } +} diff --git a/src/app/components/layout/layout.component.spec.ts b/src/app/components/layout/layout.component.spec.ts new file mode 100644 index 000000000..e9950baed --- /dev/null +++ b/src/app/components/layout/layout.component.spec.ts @@ -0,0 +1,104 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RouterTestingModule } from '@angular/router/testing'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { MinimalNodeEntryEntity } from 'alfresco-js-api'; +import { CoreModule, AlfrescoContentService, PeopleContentService } from 'ng2-alfresco-core'; +import { Observable } from 'rxjs/Observable'; + +import { BrowsingFilesService } from '../../common/services/browsing-files.service'; +import { LayoutComponent } from './layout.component'; +import { CommonModule } from './../../common/common.module'; +import { HeaderComponent } from '../header/header.component'; +import { SidenavComponent } from '../sidenav/sidenav.component'; +import { SearchComponent } from '../search/search.component'; +import { CurrentUserComponent } from '../current-user/current-user.component'; + +describe('LayoutComponent', () => { + let fixture: ComponentFixture; + let component: LayoutComponent; + let browsingFilesService: BrowsingFilesService; + let contentService: AlfrescoContentService; + let node; + + beforeEach(() => { + node = { id: 'node-id' }; + + TestBed.configureTestingModule({ + imports: [ + CoreModule, + RouterTestingModule, + CommonModule + ], + declarations: [ + LayoutComponent, + HeaderComponent, + SidenavComponent, + SearchComponent, + CurrentUserComponent + ], + providers: [ + { + provide: PeopleContentService, + useValue: { + getCurrentPerson: () => Observable.of({ entry: {} }) + } + } + ] + }); + + fixture = TestBed.createComponent(LayoutComponent); + component = fixture.componentInstance; + browsingFilesService = TestBed.get(BrowsingFilesService); + contentService = TestBed.get(AlfrescoContentService); + + fixture.detectChanges(); + }); + + it('sets current node', () => { + const currentNode = { id: 'someId' }; + + browsingFilesService.onChangeParent.next(currentNode); + + expect(component.node).toEqual(currentNode); + }); + + describe('canCreateContent()', () => { + it('returns true if node has permission', () => { + spyOn(contentService, 'hasPermission').and.returnValue(true); + + const permission = component.canCreateContent({}); + + expect(permission).toBe(true); + }); + + it('returns false if node does not have permission', () => { + spyOn(contentService, 'hasPermission').and.returnValue(false); + + const permission = component.canCreateContent({}); + + expect(permission).toBe(false); + }); + + it('returns false if node is null', () => { + const permission = component.canCreateContent(null); + + expect(permission).toBe(false); + }); + }); + }); diff --git a/src/app/components/layout/layout.component.ts b/src/app/components/layout/layout.component.ts new file mode 100644 index 000000000..7d1d67bad --- /dev/null +++ b/src/app/components/layout/layout.component.ts @@ -0,0 +1,53 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Subscription } from 'rxjs/Rx'; +import { MinimalNodeEntryEntity } from 'alfresco-js-api'; +import { AlfrescoContentService } from 'ng2-alfresco-core'; +import { BrowsingFilesService } from '../../common/services/browsing-files.service'; + +@Component({ + selector: 'app-layout', + templateUrl: './layout.component.html', + styleUrls: ['./layout.component.scss'] +}) +export class LayoutComponent implements OnInit, OnDestroy { + node: MinimalNodeEntryEntity; + + browsingFilesSubscription: Subscription; + + constructor( + private contentService: AlfrescoContentService, + private browsingFilesService: BrowsingFilesService) {} + + ngOnInit() { + this.browsingFilesSubscription = this.browsingFilesService.onChangeParent + .subscribe((node: MinimalNodeEntryEntity) => this.node = node); + } + + ngOnDestroy() { + this.browsingFilesSubscription.unsubscribe(); + } + + canCreateContent(node: MinimalNodeEntryEntity): boolean { + if (node) { + return this.contentService.hasPermission(node, 'create'); + } + return false; + } +} diff --git a/src/app/components/libraries/libraries.component.html b/src/app/components/libraries/libraries.component.html new file mode 100644 index 000000000..98524e8d0 --- /dev/null +++ b/src/app/components/libraries/libraries.component.html @@ -0,0 +1,58 @@ +
+
+ + + + +
+ +
+ + + + + + + + + + + {{ makeLibraryTitle(context.row.obj.entry) }} + + + + + + + + {{ 'APP.SITES_VISIBILITY.PUBLIC' | translate }} + + + {{ 'APP.SITES_VISIBILITY.PRIVATE' | translate }} + + + {{ 'APP.SITES_VISIBILITY.MODERATED' | translate }} + + + + + +
+
diff --git a/src/app/components/libraries/libraries.component.spec.ts b/src/app/components/libraries/libraries.component.spec.ts new file mode 100644 index 000000000..d869760e7 --- /dev/null +++ b/src/app/components/libraries/libraries.component.spec.ts @@ -0,0 +1,189 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TestBed, async } from '@angular/core/testing'; +import { Observable } from 'rxjs/Rx'; + +import { CoreModule , NodesApiService, AlfrescoApiService} from 'ng2-alfresco-core'; + +import { CommonModule } from '../../common/common.module'; +import { LibrariesComponent } from './libraries.component'; + +describe('Libraries Routed Component', () => { + let fixture; + let component: LibrariesComponent; + let nodesApi: NodesApiService; + let alfrescoApi: AlfrescoApiService; + let router: Router; + let page; + let node; + + beforeEach(() => { + page = { + list: { + entries: [ { entry: { id: 1 } }, { entry: { id: 2 } } ], + pagination: { data: 'data'} + } + }; + + node = { + id: 'nodeId', + path: { + elements: [] + } + }; + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CoreModule, + RouterTestingModule, + CommonModule + ], + declarations: [ + LibrariesComponent + ] + }) + .compileComponents().then(() => { + fixture = TestBed.createComponent(LibrariesComponent); + component = fixture.componentInstance; + + nodesApi = TestBed.get(NodesApiService); + alfrescoApi = TestBed.get(AlfrescoApiService); + router = TestBed.get(Router); + }); + })); + + beforeEach(() => { + spyOn(alfrescoApi.sitesApi, 'getSites').and.returnValue((Promise.resolve(page))); + }); + + describe('makeLibraryTooltip()', () => { + it('maps tooltip to description', () => { + node.description = 'description'; + const tooltip = component.makeLibraryTooltip(node); + + expect(tooltip).toBe(node.description); + }); + + it('maps tooltip to description', () => { + node.title = 'title'; + const tooltip = component.makeLibraryTooltip(node); + + expect(tooltip).toBe(node.title); + }); + + it('sets tooltip to empty string', () => { + const tooltip = component.makeLibraryTooltip(node); + + expect(tooltip).toBe(''); + }); + }); + + describe('makeLibraryTitle()', () => { + it('sets title with id when duplicate nodes title exists in list', () => { + node.title = 'title'; + + component.documentList.node = { + list: { + entries: [{ entry: { id: 'some-id', title: 'title' } }] + } + }; + + const title = component.makeLibraryTitle(node); + + expect(title).toContain('nodeId'); + }); + + it('sets title when no duplicate nodes title exists in list', () => { + node.title = 'title'; + + component.paging = { + list: { + entries: [{ entry: { id: 'some-id', title: 'title-some-id' } }] + } + }; + + const title = component.makeLibraryTitle(node); + + expect(title).toBe('title'); + }); + }); + + describe('Node navigation', () => { + let routerSpy; + + beforeEach(() => { + routerSpy = spyOn(router, 'navigate'); + spyOn(component, 'fetchNodes').and.callFake(val => val); + }); + + it('does not navigate when id is not passed', () => { + component.navigate(null); + + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('navigates to node id', () => { + const document = { id: 'documentId' }; + spyOn(nodesApi, 'getNode').and.returnValue(Observable.of(document)); + + component.navigate(node.id); + + fixture.detectChanges(); + + expect(routerSpy.calls.argsFor(0)[0]).toEqual(['./', document.id]); + }); + }); + + describe('onNodeDoubleClick', () => { + it('navigates to document', () => { + spyOn(component, 'navigate'); + + const event: any = { + detail: { + node: { + entry: { guid: 'node-guid' } + } + } + }; + + component.onNodeDoubleClick(event); + + expect(component.navigate).toHaveBeenCalledWith('node-guid'); + }); + + it(' does not navigate when document is not provided', () => { + spyOn(component, 'navigate'); + + const event: any = { + detail: { + node: { + entry: null + } + } + }; + + component.onNodeDoubleClick(event); + + expect(component.navigate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/components/libraries/libraries.component.ts b/src/app/components/libraries/libraries.component.ts new file mode 100644 index 000000000..e95fe8e95 --- /dev/null +++ b/src/app/components/libraries/libraries.component.ts @@ -0,0 +1,80 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, ViewChild } from '@angular/core'; +import { Router, ActivatedRoute } from '@angular/router'; +import { NodesApiService } from 'ng2-alfresco-core'; + +import { PageComponent } from '../page.component'; +import { DocumentListComponent } from 'ng2-alfresco-documentlist'; + +@Component({ + templateUrl: './libraries.component.html' +}) +export class LibrariesComponent extends PageComponent { + + @ViewChild(DocumentListComponent) + documentList: DocumentListComponent; + + constructor( + private nodesApi: NodesApiService, + private route: ActivatedRoute, + private router: Router) { + super(); + } + + makeLibraryTooltip(library: any): string { + const { description, title } = library; + + return description || title || ''; + } + + makeLibraryTitle(library: any): string { + const { title, id } = library; + let isDuplicate = false; + if (this.documentList.node) { + isDuplicate = this.documentList.node.list.entries + .some(({ entry }: any) => { + return (entry.id !== id && entry.title === title); + }); + } + + return isDuplicate ? `${title} (${id})` : `${title}`; + } + + onNodeDoubleClick(e: CustomEvent) { + const node: any = e.detail.node.entry; + + if (node && node.guid) { + this.navigate(node.guid); + } + } + + navigate(libraryId: string) { + if (libraryId) { + this.nodesApi + .getNode(libraryId, { relativePath: '/documentLibrary' }) + .subscribe(documentLibrary => { + this.router.navigate([ './', documentLibrary.id ], { relativeTo: this.route }); + }); + } + } + + fetchNodes(): void { + // todo: remove once all views migrate to native data source + } +} diff --git a/src/app/components/login/login.component.spec.ts b/src/app/components/login/login.component.spec.ts index 1580b1e82..5431dc0e9 100644 --- a/src/app/components/login/login.component.spec.ts +++ b/src/app/components/login/login.component.spec.ts @@ -25,120 +25,120 @@ import { LoginModule } from 'ng2-alfresco-login'; import { LoginComponent } from './login.component'; describe('LoginComponent', () => { - let router: Router; - let route: ActivatedRoute; - let authService: AlfrescoAuthenticationService; - let userPrefService: UserPreferencesService; + let router: Router; + let route: ActivatedRoute; + let authService: AlfrescoAuthenticationService; + let userPrefService: UserPreferencesService; - class TestConfig { - private testBed; - private componentInstance; - private fixture; + class TestConfig { + private testBed; + private componentInstance; + private fixture; - constructor(config: any = {}) { - const routerProvider = { - provide: Router, - useValue: { - navigateByUrl: jasmine.createSpy('navigateByUrl'), - navigate: jasmine.createSpy('navigate') + constructor(config: any = {}) { + const routerProvider = { + provide: Router, + useValue: { + navigateByUrl: jasmine.createSpy('navigateByUrl'), + navigate: jasmine.createSpy('navigate') + } + }; + + const authProvider = { + provide: AlfrescoAuthenticationService, + useValue: { + isEcmLoggedIn: jasmine.createSpy('navigateByUrl') + .and.returnValue(config.isEcmLoggedIn || false) + } + }; + + const preferencesProvider = { + provide: UserPreferencesService, + useValue: { + setStoragePrefix: jasmine.createSpy('setStoragePrefix') + } + }; + + this.testBed = TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + LoginModule + ], + declarations: [ + LoginComponent + ], + providers: [ + routerProvider, + authProvider, + preferencesProvider, + { + provide: ActivatedRoute, + useValue: { + params: Observable.of({ redirect: config.redirect }) + } + } + ] + }); + + this.fixture = TestBed.createComponent(LoginComponent); + this.componentInstance = this.fixture.componentInstance; + this.fixture.detectChanges(); } - }; - const authProvider = { - provide: AlfrescoAuthenticationService, - useValue: { - isEcmLoggedIn: jasmine.createSpy('navigateByUrl') - .and.returnValue(config.isEcmLoggedIn || false) + get userPrefService() { + return TestBed.get(UserPreferencesService); } - }; - const preferencesProvider = { - provide: UserPreferencesService, - useValue: { - setStoragePrefix: jasmine.createSpy('setStoragePrefix') + get authService() { + return TestBed.get(AlfrescoAuthenticationService); } - }; - this.testBed = TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - LoginModule - ], - declarations: [ - LoginComponent - ], - providers: [ - routerProvider, - authProvider, - preferencesProvider, - { - provide: ActivatedRoute, - useValue: { - params: Observable.of({ redirect: config.redirect }) - } - } - ] - }); + get routerService() { + return TestBed.get(Router); + } - this.fixture = TestBed.createComponent(LoginComponent); - this.componentInstance = this.fixture.componentInstance; - this.fixture.detectChanges(); + get component() { + return this.componentInstance; + } } - get userPrefService() { - return TestBed.get(UserPreferencesService); - } + it('load app when user is already logged in', () => { + const testConfig = new TestConfig({ + isEcmLoggedIn: true + }); - get authService() { - return TestBed.get(AlfrescoAuthenticationService); - } - - get routerService() { - return TestBed.get(Router); - } - - get component() { - return this.componentInstance; - } - } - - it('load app when user is already logged in', () => { - const testConfig = new TestConfig({ - isEcmLoggedIn: true + expect(testConfig.routerService.navigateByUrl).toHaveBeenCalled(); }); - expect(testConfig.routerService.navigateByUrl).toHaveBeenCalled(); - }); + it('requires user to be logged in', () => { + const testConfig = new TestConfig({ + isEcmLoggedIn: false, + redirect: '/personal-files' + }); - it('requires user to be logged in', () => { - const testConfig = new TestConfig({ - isEcmLoggedIn: false, - redirect: '/personal-files' + expect(testConfig.routerService.navigate).toHaveBeenCalledWith(['/login', {}]); }); - expect(testConfig.routerService.navigate).toHaveBeenCalledWith(['/login', {}]); - }); + describe('onLoginSuccess()', () => { + let testConfig; - describe('onLoginSuccess()', () => { - let testConfig; + beforeEach(() => { + testConfig = new TestConfig({ + isEcmLoggedIn: false, + redirect: 'somewhere-over-the-rainbow' + }); + }); - beforeEach(() => { - testConfig = new TestConfig({ - isEcmLoggedIn: false, - redirect: 'somewhere-over-the-rainbow' - }); + it('redirects on success', () => { + testConfig.component.onLoginSuccess(); + + expect(testConfig.routerService.navigateByUrl).toHaveBeenCalledWith('somewhere-over-the-rainbow'); + }); + + it('sets user preference store prefix', () => { + testConfig.component.onLoginSuccess({ username: 'bogus' }); + + expect(testConfig.userPrefService.setStoragePrefix).toHaveBeenCalledWith('bogus'); + }); }); - - it('redirects on success', () => { - testConfig.component.onLoginSuccess(); - - expect(testConfig.routerService.navigateByUrl).toHaveBeenCalledWith('somewhere-over-the-rainbow'); - }); - - it('sets user preference store prefix', () => { - testConfig.component.onLoginSuccess({ username: 'bogus' }); - - expect(testConfig.userPrefService.setStoragePrefix).toHaveBeenCalledWith('bogus'); - }); - }); }); diff --git a/src/app/components/login/login.component.ts b/src/app/components/login/login.component.ts index bd0713c51..87413a876 100644 --- a/src/app/components/login/login.component.ts +++ b/src/app/components/login/login.component.ts @@ -22,48 +22,48 @@ import { Validators } from '@angular/forms'; import { AlfrescoAuthenticationService, UserPreferencesService } from 'ng2-alfresco-core'; const skipRedirectUrls: string[] = [ - '/logout', - '/personal-files' + '/logout', + '/personal-files' ]; @Component({ - templateUrl: './login.component.html' + templateUrl: './login.component.html' }) export class LoginComponent { - private redirectUrl = ''; + private redirectUrl = ''; - constructor( - private router: Router, - private route: ActivatedRoute, - private auth: AlfrescoAuthenticationService, - private userPreferences: UserPreferencesService - ) { - if (auth.isEcmLoggedIn()) { - this.redirect(); + constructor( + private router: Router, + private route: ActivatedRoute, + private auth: AlfrescoAuthenticationService, + private userPreferences: UserPreferencesService + ) { + if (auth.isEcmLoggedIn()) { + this.redirect(); + } + + route.params.subscribe((params: any) => { + if (skipRedirectUrls.indexOf(params.redirect) > -1) { + const remainingParams = Object.assign({}, params); + + delete remainingParams.redirect; + + router.navigate(['/login', remainingParams]); + } + + this.redirectUrl = params.redirect; + }); } - route.params.subscribe((params: any) => { - if (skipRedirectUrls.indexOf(params.redirect) > -1) { - const remainingParams = Object.assign({}, params); - - delete remainingParams.redirect; - - router.navigate(['/login', remainingParams]); - } - - this.redirectUrl = params.redirect; - }); - } - - redirect() { - this.router.navigateByUrl(this.redirectUrl || ''); - } - - onLoginSuccess(data) { - if (data && data.username) { - this.userPreferences.setStoragePrefix(data.username); + redirect() { + this.router.navigateByUrl(this.redirectUrl || ''); + } + + onLoginSuccess(data) { + if (data && data.username) { + this.userPreferences.setStoragePrefix(data.username); + } + this.redirect(); } - this.redirect(); - } } diff --git a/src/app/components/page.component.spec.ts b/src/app/components/page.component.spec.ts new file mode 100644 index 000000000..fa45ed439 --- /dev/null +++ b/src/app/components/page.component.spec.ts @@ -0,0 +1,295 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; + +import { PageComponent } from './page.component'; + +class TestClass extends PageComponent { + node: any; + + constructor() { + super(); + } + + fetchNodes(parentNodeId?: string, options?: any) { + // abstract + } +} + +describe('PageComponent', () => { + let component; + + beforeEach(() => { + component = new TestClass(); + }); + + describe('getParentNodeId()', () => { + it('returns parent node id when node is set', () => { + component.node = { id: 'node-id' }; + + expect(component.getParentNodeId()).toBe('node-id'); + }); + + it('returns null when node is not set', () => { + component.node = null; + + expect(component.getParentNodeId()).toBe(null); + }); + }); + + describe('onFetchError()', () => { + it('sets isLoading state to false', () => { + component.isLoading = true; + + component.onFetchError(); + + expect(component.isLoading).toBe(false); + }); + }); + + describe('onPaginationChange()', () => { + it('fetch children nodes for current node id', () => { + component.node = { id: 'node-id' }; + spyOn(component, 'fetchNodes').and.stub(); + + component.onPaginationChange({pagination: 'pagination-data'}); + + expect(component.fetchNodes).toHaveBeenCalledWith('node-id', { pagination: 'pagination-data' }); + }); + }); + + describe('onPageLoaded()', () => { + let page; + + beforeEach(() => { + page = { + list: { + entries: ['a', 'b', 'c'], + pagination: {} + } + }; + + component.isLoading = true; + component.isEmpty = true; + component.onPageLoaded(page); + }); + + it('sets isLoading state to false', () => { + expect(component.isLoading).toBe(false); + }); + + it('sets component paging data', () => { + expect(component.paging).toBe(page); + }); + + it('sets component pagination data', () => { + expect(component.pagination).toBe(page.list.pagination); + }); + + it('sets component isEmpty state', () => { + expect(component.isEmpty).toBe(false); + }); + }); + + describe('hasSelection()', () => { + it('returns true when it has nodes selected', () => { + const hasSelection = component.hasSelection([ {}, {} ]); + expect(hasSelection).toBe(true); + }); + + it('returns false when it has no selections', () => { + const hasSelection = component.hasSelection([]); + expect(hasSelection).toBe(false); + }); + }); + + describe('filesOnlySelected()', () => { + it('return true if only files are selected', () => { + const selected = [ { entry: { isFile: true } }, { entry: { isFile: true } } ]; + expect(component.filesOnlySelected(selected)).toBe(true); + }); + + it('return false if selection contains others types', () => { + const selected = [ { entry: { isFile: true } }, { entry: { isFolder: true } } ]; + expect(component.filesOnlySelected(selected)).toBe(false); + }); + + it('return false if selection contains no files', () => { + const selected = [ { entry: { isFolder: true } } ]; + expect(component.filesOnlySelected(selected)).toBe(false); + }); + + it('return false no selection', () => { + const selected = []; + expect(component.filesOnlySelected(selected)).toBe(false); + }); + }); + + describe('foldersOnlySelected()', () => { + it('return true if only folders are selected', () => { + const selected = [ { entry: { isFolder: true } }, { entry: { isFolder: true } } ]; + expect(component.foldersOnlySelected(selected)).toBe(true); + }); + + it('return false if selection contains others types', () => { + const selected = [ { entry: { isFile: true } }, { entry: { isFolder: true } } ]; + expect(component.foldersOnlySelected(selected)).toBe(false); + }); + + it('return false if selection contains no files', () => { + const selected = [ { entry: { isFile: true } } ]; + expect(component.foldersOnlySelected(selected)).toBe(false); + }); + + it('return false no selection', () => { + const selected = []; + expect(component.foldersOnlySelected(selected)).toBe(false); + }); + }); + + describe('isFileSelected()', () => { + it('returns true if selected node is file', () => { + const selection = [ { entry: { isFile: true } } ]; + expect(component.isFileSelected(selection)).toBe(true); + }); + + it('returns false if selected node is folder', () => { + const selection = [ { entry: { isFolder: true } } ]; + expect(component.isFileSelected(selection)).toBe(false); + }); + + it('returns false if there are more than one selections', () => { + const selection = [ { entry: { isFile: true } }, { entry: { isFile: true } } ]; + expect(component.isFileSelected(selection)).toBe(false); + }); + }); + + describe('canEditFolder()', () => { + it('returns true if selected node is folder', () => { + const selection = [ { entry: { isFolder: true } } ]; + spyOn(component, 'nodeHasPermission').and.returnValue(true); + + expect(component.canEditFolder(selection)).toBe(true); + }); + + it('returns false if selected node is file', () => { + const selection = [ { entry: { isFile: true } } ]; + expect(component.canEditFolder(selection)).toBe(false); + }); + + it('returns false if there are more than one selections', () => { + const selection = [ { entry: { isFolder: true } }, { entry: { isFolder: true } } ]; + expect(component.canEditFolder(selection)).toBe(false); + }); + + it('returns false folder dows not have edit permission', () => { + spyOn(component, 'nodeHasPermission').and.returnValue(false); + const selection = [ { entry: { isFolder: true } } ]; + + expect(component.canEditFolder(selection)).toBe(false); + }); + }); + + describe('canDelete()', () => { + it('returns false if node has no delete permission', () => { + const selection = [ { entry: { } } ]; + spyOn(component, 'nodeHasPermission').and.returnValue(false); + + expect(component.canDelete(selection)).toBe(false); + }); + + it('returns true if node has delete permission', () => { + const selection = [ { entry: { } } ]; + spyOn(component, 'nodeHasPermission').and.returnValue(true); + + expect(component.canDelete(selection)).toBe(true); + }); + }); + + describe('canMove()', () => { + it('returns true if node can be deleted', () => { + const selection = [ { entry: { } } ]; + spyOn(component, 'canDelete').and.returnValue(true); + + expect(component.canMove(selection)).toBe(true); + }); + + it('returns false if node can not be deleted', () => { + const selection = [ { entry: { } } ]; + spyOn(component, 'canDelete').and.returnValue(false); + + expect(component.canMove(selection)).toBe(false); + }); + }); + + describe('canPreviewFile()', () => { + it('it returns true if node is file', () => { + const selection = [{ entry: { isFile: true } }]; + + expect(component.canPreviewFile(selection)).toBe(true); + }); + + it('it returns false if node is folder', () => { + const selection = [{ entry: { isFolder: true } }]; + + expect(component.canPreviewFile(selection)).toBe(false); + }); + }); + + describe('canShareFile()', () => { + it('it returns true if node is file', () => { + const selection = [{ entry: { isFile: true } }]; + + expect(component.canShareFile(selection)).toBe(true); + }); + + it('it returns false if node is folder', () => { + const selection = [{ entry: { isFolder: true } }]; + + expect(component.canShareFile(selection)).toBe(false); + }); + }); + + describe('canDownloadFile()', () => { + it('it returns true if node is file', () => { + const selection = [{ entry: { isFile: true } }]; + + expect(component.canDownloadFile(selection)).toBe(true); + }); + + it('it returns false if node is folder', () => { + const selection = [{ entry: { isFolder: true } }]; + + expect(component.canDownloadFile(selection)).toBe(false); + }); + }); + + describe('nodeHasPermission()', () => { + it('returns true is has permission', () => { + const node = { allowableOperations: ['some-operation'] }; + + expect(component.nodeHasPermission(node, 'some-operation')).toBe(true); + }); + + it('returns true is has permission', () => { + const node = { allowableOperations: ['other-operation'] }; + + expect(component.nodeHasPermission(node, 'some-operation')).toBe(false); + }); + }); +}); diff --git a/src/app/components/page.component.ts b/src/app/components/page.component.ts new file mode 100644 index 000000000..59fcb0abc --- /dev/null +++ b/src/app/components/page.component.ts @@ -0,0 +1,124 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MinimalNodeEntity, MinimalNodeEntryEntity, NodePaging, Pagination } from 'alfresco-js-api'; + +export abstract class PageComponent { + + title: string = 'Page'; + + isLoading: boolean = false; + isEmpty: boolean = true; + + paging: NodePaging; + pagination: Pagination; + + node: MinimalNodeEntryEntity; + + abstract fetchNodes(parentNodeId?: string, options?: any): void; + + onFetchError(error: any) { + this.isLoading = false; + } + + getParentNodeId(): string { + return this.node ? this.node.id : null; + } + + onPaginationChange(pagination: any) { + this.fetchNodes(this.getParentNodeId(), pagination); + } + + onPageLoaded(page: NodePaging) { + this.isLoading = false; + this.paging = page; + this.pagination = page.list.pagination; + this.isEmpty = !(page.list.entries && page.list.entries.length > 0); + } + + hasSelection(selection: Array): boolean { + return selection && selection.length > 0; + } + + filesOnlySelected(selection: Array): boolean { + if (this.hasSelection(selection)) { + return selection.every(entity => entity.entry && entity.entry.isFile); + } + return false; + } + + foldersOnlySelected(selection: Array): boolean { + if (this.hasSelection(selection)) { + return selection.every(entity => entity.entry && entity.entry.isFolder); + } + return false; + } + + isFileSelected(selection: Array): boolean { + if (selection && selection.length === 1) { + let entry = selection[0].entry; + + if (entry && entry.isFile) { + return true; + } + } + return false; + } + + canEditFolder(selection: Array): boolean { + if (selection && selection.length === 1) { + let entry = selection[0].entry; + + if (entry && entry.isFolder) { + return this.nodeHasPermission(entry, 'update'); + } + } + return false; + } + + canDelete(selection: Array = []): boolean { + return selection.every(node => node.entry && this.nodeHasPermission(node.entry, 'delete')); + } + + canMove(selection: Array): boolean { + return this.canDelete(selection); + } + + canPreviewFile(selection: Array): boolean { + return this.isFileSelected(selection); + } + + canShareFile(selection: Array): boolean { + return this.isFileSelected(selection); + } + + canDownloadFile(selection: Array): boolean { + return this.isFileSelected(selection); + } + + nodeHasPermission(node: MinimalNodeEntryEntity, permission: string) { + if (node && permission) { + const { allowableOperations = [] } = (node || {}); + + if (allowableOperations.indexOf(permission) > -1) { + return true; + } + } + + return false; + } +} diff --git a/src/app/components/preview/preview.component.html b/src/app/components/preview/preview.component.html new file mode 100644 index 000000000..2592514c9 --- /dev/null +++ b/src/app/components/preview/preview.component.html @@ -0,0 +1,3 @@ + + + diff --git a/src/app/components/preview/preview.component.scss b/src/app/components/preview/preview.component.scss new file mode 100644 index 000000000..6a2cae035 --- /dev/null +++ b/src/app/components/preview/preview.component.scss @@ -0,0 +1,4 @@ +.app-preview { + width: 100%; + height: 100%; +} diff --git a/src/app/components/preview/preview.component.ts b/src/app/components/preview/preview.component.ts new file mode 100644 index 000000000..b6f63bf4e --- /dev/null +++ b/src/app/components/preview/preview.component.ts @@ -0,0 +1,56 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, OnInit, ViewEncapsulation } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AlfrescoApiService } from 'ng2-alfresco-core'; + +@Component({ + selector: 'app-preview', + templateUrl: 'preview.component.html', + styleUrls: ['preview.component.scss'], + encapsulation: ViewEncapsulation.None, + host: { 'class': 'app-preview' } +}) +export class PreviewComponent implements OnInit { + + nodeId: string = null; + + constructor( + private router: Router, + private route: ActivatedRoute, + private apiService: AlfrescoApiService) {} + + ngOnInit() { + this.route.params.subscribe(params => { + const id = params.nodeId; + if (id) { + this.apiService.getInstance().nodes.getNodeInfo(id).then( + (node) => { + if (node && node.isFile) { + this.nodeId = id; + return; + } + this.router.navigate(['/personal-files', id]); + }, + () => this.router.navigate(['/personal-files', id]) + ); + } + }); + } + +} diff --git a/src/app/components/recent-files/recent-files.component.html b/src/app/components/recent-files/recent-files.component.html new file mode 100644 index 000000000..a70a9e526 --- /dev/null +++ b/src/app/components/recent-files/recent-files.component.html @@ -0,0 +1,122 @@ +
+
+ + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + {{ value }} + + + + + + + + + + + + + + +
+
diff --git a/src/app/components/recent-files/recent-files.component.spec.ts b/src/app/components/recent-files/recent-files.component.spec.ts new file mode 100644 index 000000000..ba1defdc5 --- /dev/null +++ b/src/app/components/recent-files/recent-files.component.spec.ts @@ -0,0 +1,152 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TestBed, async } from '@angular/core/testing'; +import { Observable } from 'rxjs/Rx'; + +import { CoreModule, AlfrescoApiService } from 'ng2-alfresco-core'; + +import { CommonModule } from '../../common/common.module'; +import { ContentManagementService } from '../../common/services/content-management.service'; +import { RecentFilesComponent } from './recent-files.component'; + +describe('RecentFiles Routed Component', () => { + let fixture; + let component; + let router: Router; + let alfrescoApi: AlfrescoApiService; + let contentService: ContentManagementService; + let page; + let person; + + beforeEach(() => { + page = { + list: { + entries: [ { entry: { id: 1 } }, { entry: { id: 2 } } ], + pagination: { data: 'data'} + } + }; + + person = { entry: { id: 'bogus' } }; + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CoreModule, + RouterTestingModule, + CommonModule + ], + declarations: [ + RecentFilesComponent + ] + }) + .compileComponents().then(() => { + fixture = TestBed.createComponent(RecentFilesComponent); + component = fixture.componentInstance; + + router = TestBed.get(Router); + contentService = TestBed.get(ContentManagementService); + alfrescoApi = TestBed.get(AlfrescoApiService); + }); + })); + + beforeEach(() => { + spyOn(alfrescoApi.peopleApi, 'getPerson').and.returnValue(Promise.resolve({ + entry: { id: 'personId' } + })); + + spyOn(alfrescoApi.searchApi, 'search').and.returnValue(Promise.resolve(page)); + }); + + describe('OnInit()', () => { + beforeEach(() => { + spyOn(component, 'refresh').and.stub(); + }); + + it('should reload nodes on onDeleteNode event', () => { + fixture.detectChanges(); + + contentService.deleteNode.next(); + + expect(component.refresh).toHaveBeenCalled(); + }); + + it('should reload on onRestoreNode event', () => { + fixture.detectChanges(); + + contentService.restoreNode.next(); + + expect(component.refresh).toHaveBeenCalled(); + }); + + it('should reload on toggleFavorite event', () => { + fixture.detectChanges(); + + contentService.toggleFavorite.next(); + + expect(component.refresh).toHaveBeenCalled(); + }); + + it('should reload on move node event', () => { + fixture.detectChanges(); + + contentService.moveNode.next(); + + expect(component.refresh).toHaveBeenCalled(); + }); + }); + + describe('onNodeDoubleClick()', () => { + beforeEach(() => { + spyOn(component, 'fetchNodes').and.callFake(val => val); + }); + + it('open preview if node is file', () => { + spyOn(router, 'navigate').and.stub(); + const node: any = { isFile: true }; + + component.onNodeDoubleClick(node); + fixture.detectChanges(); + + expect(router.navigate).toHaveBeenCalledWith(['/preview', node.id]); + }); + + it('does not open preview if node is folder', () => { + spyOn(router, 'navigate').and.stub(); + const node: any = { isFolder: true }; + + component.onNodeDoubleClick(node); + fixture.detectChanges(); + + expect(router.navigate).not.toHaveBeenCalled(); + }); + }); + + describe('refresh', () => { + it('should call document list reload', () => { + spyOn(component.documentList, 'reload'); + fixture.detectChanges(); + + component.refresh(); + + expect(component.documentList.reload).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/components/recent-files/recent-files.component.ts b/src/app/components/recent-files/recent-files.component.ts new file mode 100644 index 000000000..4bbe2d546 --- /dev/null +++ b/src/app/components/recent-files/recent-files.component.ts @@ -0,0 +1,75 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Subscription } from 'rxjs/Rx'; +import { Component, ViewChild, OnInit, OnDestroy } from '@angular/core'; +import { Router } from '@angular/router'; +import { MinimalNodeEntryEntity } from 'alfresco-js-api'; +import { DocumentListComponent } from 'ng2-alfresco-documentlist'; + +import { ContentManagementService } from '../../common/services/content-management.service'; +import { PageComponent } from '../page.component'; + +@Component({ + templateUrl: './recent-files.component.html' +}) +export class RecentFilesComponent extends PageComponent implements OnInit, OnDestroy { + + @ViewChild(DocumentListComponent) + documentList: DocumentListComponent; + + private onDeleteNode: Subscription; + private onMoveNode: Subscription; + private onRestoreNode: Subscription; + private onToggleFavorite: Subscription; + + constructor( + private router: Router, + private content: ContentManagementService) { + super(); + } + + ngOnInit() { + this.onDeleteNode = this.content.deleteNode.subscribe(() => this.refresh()); + this.onMoveNode = this.content.moveNode.subscribe(() => this.refresh()); + this.onRestoreNode = this.content.restoreNode.subscribe(() => this.refresh()); + this.onToggleFavorite = this.content.toggleFavorite.subscribe(() => this.refresh()); + } + + ngOnDestroy() { + this.onDeleteNode.unsubscribe(); + this.onMoveNode.unsubscribe(); + this.onRestoreNode.unsubscribe(); + this.onToggleFavorite.unsubscribe(); + } + + onNodeDoubleClick(node: MinimalNodeEntryEntity) { + if (node && node.isFile) { + this.router.navigate(['/preview', node.id]); + } + } + + fetchNodes(): void { + // todo: remove once all views migrate to native data source + } + + refresh(): void { + if (this.documentList) { + this.documentList.reload(); + } + } +} diff --git a/src/app/components/search/search.component.html b/src/app/components/search/search.component.html new file mode 100644 index 000000000..516b78e5d --- /dev/null +++ b/src/app/components/search/search.component.html @@ -0,0 +1,6 @@ + + diff --git a/src/app/components/search/search.component.scss b/src/app/components/search/search.component.scss new file mode 100644 index 000000000..580754e74 --- /dev/null +++ b/src/app/components/search/search.component.scss @@ -0,0 +1,9 @@ +@import './../../ui/variables'; + +adf-search-control { + color: $alfresco-white; +} + +:host { + height: $app-menu-height; +} diff --git a/src/app/components/search/search.component.spec.ts b/src/app/components/search/search.component.spec.ts new file mode 100644 index 000000000..09f9d23e7 --- /dev/null +++ b/src/app/components/search/search.component.spec.ts @@ -0,0 +1,71 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed, async } from '@angular/core/testing'; +import { CoreModule, AppConfigService } from 'ng2-alfresco-core'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { SearchComponent } from './search.component'; +import { CommonModule } from './../../common/common.module'; + +describe('SearchComponent', () => { + let fixture; + let component; + let router: Router; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CoreModule, + RouterTestingModule, + CommonModule + ], + declarations: [ + SearchComponent + ] + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(SearchComponent); + component = fixture.componentInstance; + router = TestBed.get(Router); + + fixture.detectChanges(); + }); + })); + + describe('onNodeClicked()', () => { + it('opens preview if node is file', () => { + spyOn(router, 'navigate').and.stub(); + const node = { entry: { isFile: true, id: 'node-id' } }; + + component.onNodeClicked(node); + + expect(router.navigate).toHaveBeenCalledWith(['/preview', node.entry.id]); + }); + + it('navigates if node is folder', () => { + const node = { entry: { isFolder: true } }; + spyOn(router, 'navigate'); + + component.onNodeClicked(node); + + expect(router.navigate).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/components/search/search.component.ts b/src/app/components/search/search.component.ts new file mode 100644 index 000000000..640b39091 --- /dev/null +++ b/src/app/components/search/search.component.ts @@ -0,0 +1,45 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component } from '@angular/core'; +import { Router } from '@angular/router'; + +import { MinimalNodeEntity } from 'alfresco-js-api'; + +@Component({ + selector: 'app-search', + templateUrl: 'search.component.html', + styleUrls: ['search.component.scss'] +}) +export class SearchComponent { + + searchTerm: string = ''; + + constructor( + private router: Router) { + } + + onNodeClicked(node: MinimalNodeEntity) { + if (node && node.entry) { + if (node.entry.isFile) { + this.router.navigate(['/preview', node.entry.id]); + } else if (node.entry.isFolder) { + this.router.navigate([ '/personal-files', node.entry.id ]); + } + } + } +} diff --git a/src/app/components/shared-files/shared-files.component.html b/src/app/components/shared-files/shared-files.component.html new file mode 100644 index 000000000..cbcacd676 --- /dev/null +++ b/src/app/components/shared-files/shared-files.component.html @@ -0,0 +1,130 @@ +
+
+ + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + {{ value }} + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/src/app/components/shared-files/shared-files.component.spec.ts b/src/app/components/shared-files/shared-files.component.spec.ts new file mode 100644 index 000000000..169788d30 --- /dev/null +++ b/src/app/components/shared-files/shared-files.component.spec.ts @@ -0,0 +1,161 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Router } from '@angular/router'; +import { TestBed, async, fakeAsync, tick } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { AlfrescoApiService } from 'ng2-alfresco-core'; + +import { CommonModule } from '../../common/common.module'; +import { ContentManagementService } from '../../common/services/content-management.service'; +import { SharedFilesComponent } from './shared-files.component'; + +describe('SharedFilesComponent', () => { + let fixture; + let component: SharedFilesComponent; + let contentService: ContentManagementService; + let nodeService; + let alfrescoApi: AlfrescoApiService; + let router: Router; + let page; + + beforeEach(() => { + page = { + list: { + entries: [ { entry: { id: 1 } }, { entry: { id: 2 } } ], + pagination: { data: 'data'} + } + }; + }); + + beforeEach(async(() => { + TestBed + .configureTestingModule({ + imports: [ + RouterTestingModule, + CommonModule + ], + declarations: [ + SharedFilesComponent + ] + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(SharedFilesComponent); + component = fixture.componentInstance; + + contentService = TestBed.get(ContentManagementService); + alfrescoApi = TestBed.get(AlfrescoApiService); + nodeService = alfrescoApi.getInstance().nodes; + router = TestBed.get(Router); + }); + + })); + + beforeEach(() => { + spyOn(alfrescoApi.sharedLinksApi, 'findSharedLinks').and.returnValue(Promise.resolve(page)); + }); + + describe('OnInit', () => { + beforeEach(() => { + spyOn(component, 'refresh').and.callFake(val => val); + }); + + it('should refresh on deleteNode event', () => { + fixture.detectChanges(); + + contentService.deleteNode.next(); + + expect(component.refresh).toHaveBeenCalled(); + }); + + it('should refresh on restoreNode event', () => { + fixture.detectChanges(); + + contentService.restoreNode.next(); + + expect(component.refresh).toHaveBeenCalled(); + }); + + it('should refresh on favorite toggle event', () => { + fixture.detectChanges(); + + contentService.toggleFavorite.next(); + + expect(component.refresh).toHaveBeenCalled(); + }); + + it('should reload on move node event', () => { + fixture.detectChanges(); + + contentService.moveNode.next(); + + expect(component.refresh).toHaveBeenCalled(); + }); + }); + + describe('onNodeDoubleClick()', () => { + beforeEach(() => { + spyOn(component, 'fetchNodes').and.callFake(val => val); + fixture.detectChanges(); + }); + + it('opens viewer if node is file', fakeAsync(() => { + spyOn(router, 'navigate').and.stub(); + const link = { nodeId: 'nodeId' }; + const node = { entry: { isFile: true, id: 'nodeId' } }; + + spyOn(nodeService, 'getNode').and.returnValue(Promise.resolve(node)); + component.onNodeDoubleClick(link); + tick(); + + expect(router.navigate).toHaveBeenCalledWith(['/preview', node.entry.id]); + })); + + it('does nothing if node is folder', fakeAsync(() => { + spyOn(router, 'navigate').and.stub(); + spyOn(nodeService, 'getNode').and.returnValue(Promise.resolve({ entry: { isFile: false } })); + const link = { nodeId: 'nodeId' }; + + component.onNodeDoubleClick(link); + tick(); + + expect(router.navigate).not.toHaveBeenCalled(); + })); + + it('does nothing if link data is not passed', () => { + spyOn(router, 'navigate').and.stub(); + spyOn(nodeService, 'getNode').and.returnValue(Promise.resolve({ entry: { isFile: true } })); + + component.onNodeDoubleClick(null); + + expect(router.navigate).not.toHaveBeenCalled(); + }); + }); + + describe('refresh', () => { + it('should call document list reload', () => { + spyOn(component.documentList, 'reload'); + fixture.detectChanges(); + + component.refresh(); + + expect(component.documentList.reload).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/components/shared-files/shared-files.component.ts b/src/app/components/shared-files/shared-files.component.ts new file mode 100644 index 000000000..90410e00c --- /dev/null +++ b/src/app/components/shared-files/shared-files.component.ts @@ -0,0 +1,88 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, OnInit, ViewChild, OnDestroy } from '@angular/core'; +import { Router } from '@angular/router'; +import { Subscription } from 'rxjs/Rx'; +import { MinimalNodeEntity } from 'alfresco-js-api'; +import { AlfrescoApiService } from 'ng2-alfresco-core'; +import { DocumentListComponent } from 'ng2-alfresco-documentlist'; + +import { ContentManagementService } from '../../common/services/content-management.service'; +import { PageComponent } from '../page.component'; + +@Component({ + templateUrl: './shared-files.component.html' +}) +export class SharedFilesComponent extends PageComponent implements OnInit, OnDestroy { + + @ViewChild(DocumentListComponent) + documentList: DocumentListComponent; + + private onDeleteNode: Subscription; + private onMoveNode: Subscription; + private onRestoreNode: Subscription; + private onToggleFavorite: Subscription; + + constructor( + private router: Router, + private content: ContentManagementService, + private apiService: AlfrescoApiService) { + super(); + } + + ngOnInit() { + this.onDeleteNode = this.content.deleteNode.subscribe(() => this.refresh()); + this.onMoveNode = this.content.moveNode.subscribe(() => this.refresh()); + this.onRestoreNode = this.content.restoreNode.subscribe(() => this.refresh()); + this.onToggleFavorite = this.content.toggleFavorite.subscribe(() => this.refresh()); + } + + ngOnDestroy() { + this.onDeleteNode.unsubscribe(); + this.onMoveNode.unsubscribe(); + this.onRestoreNode.unsubscribe(); + this.onToggleFavorite.unsubscribe(); + } + + onNodeDoubleClick(link: { nodeId?: string }) { + if (link && link.nodeId) { + this.apiService.nodesApi.getNode(link.nodeId).then( + (node: MinimalNodeEntity) => { + if (node && node.entry && node.entry.isFile) { + this.router.navigate(['/preview', node.entry.id]); + } + } + ); + } + } + + fetchNodes(parentNodeId?: string) { + // todo: remove once all views migrate to native data source + } + + /** @override */ + isFileSelected(selection: Array): boolean { + return selection && selection.length === 1; + } + + refresh(): void { + if (this.documentList) { + this.documentList.reload(); + } + } +} diff --git a/src/app/components/sidenav/sidenav.component.html b/src/app/components/sidenav/sidenav.component.html new file mode 100644 index 000000000..f19dab525 --- /dev/null +++ b/src/app/components/sidenav/sidenav.component.html @@ -0,0 +1,68 @@ +
+
+ + + + + + + + + + + +
+ + +
diff --git a/src/app/components/sidenav/sidenav.component.scss b/src/app/components/sidenav/sidenav.component.scss new file mode 100644 index 000000000..3a6bb0630 --- /dev/null +++ b/src/app/components/sidenav/sidenav.component.scss @@ -0,0 +1,78 @@ +@import '../../ui/_variables.scss'; + +$sidenav-section--h-padding: 24px; +$sidenav-section--v-padding: 8px; + +$sidenav-menu-item--h-padding: 24px; +$sidenav-menu-item--v-padding: 12px; + +$sidenav-menu-item--icon-size: 24px; + +:host { + .sidenav { + display: flex; + flex: 1; + flex-direction: column; + + &__section { + padding: + $sidenav-section--v-padding + $sidenav-section--h-padding; + + border-bottom: 1px solid $alfresco-divider-color; + position: relative; + + &--new { + padding-top: 2 * $sidenav-section--v-padding; + padding-bottom: 2 * $sidenav-section--v-padding; + } + + &--new__button { + width: 100%; + color: $alfresco-white; + background-color: $alfresco-primary-accent--default; + } + } + + &-menu { + margin: 0 -1 * $sidenav-section--h-padding; + padding: 0; + list-style-type: none; + + &__item { + &-link { + padding: + $sidenav-menu-item--v-padding + $sidenav-menu-item--h-padding; + + padding-left: $sidenav-menu-item--h-padding + 16px + 24px; + position: relative; + display: block; + color: $alfresco-secondary-text-color; + text-decoration: none; + + & > .material-icons { + position: absolute; + top: 50%; + left: $sidenav-menu-item--h-padding; + margin-top: -14px; + } + + &--active { + color: $alfresco-primary-accent--default; + } + + &:not(&--active):hover { + color: $alfresco-primary-text-color; + } + + &.disabled { + cursor: default !important; + color: $alfresco-secondary-text-color !important; + opacity: .25; + } + } + } + } + } +} diff --git a/src/app/components/sidenav/sidenav.component.spec.ts b/src/app/components/sidenav/sidenav.component.spec.ts new file mode 100644 index 000000000..048df2247 --- /dev/null +++ b/src/app/components/sidenav/sidenav.component.spec.ts @@ -0,0 +1,85 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed, async } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { AlfrescoContentService } from 'ng2-alfresco-core'; + +import { BrowsingFilesService } from '../../common/services/browsing-files.service'; + +import { SidenavComponent } from './sidenav.component'; +import { CommonModule } from './../../common/common.module'; + +describe('SidenavComponent', () => { + let fixture; + let component: SidenavComponent; + let contentService: AlfrescoContentService; + let browsingService: BrowsingFilesService; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + CommonModule + ], + declarations: [ + SidenavComponent + ] + }) + .compileComponents() + .then(() => { + contentService = TestBed.get(AlfrescoContentService); + browsingService = TestBed.get(BrowsingFilesService); + + fixture = TestBed.createComponent(SidenavComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + })); + + it('updates node on change', () => { + const node = { entry: { id: 'someNodeId' } }; + + browsingService.onChangeParent.next(node); + + expect(component.node).toBe(node); + }); + + it('can create content', () => { + spyOn(contentService, 'hasPermission').and.returnValue(true); + const node: any = {}; + + expect(component.canCreateContent(node)).toBe(true); + expect(contentService.hasPermission).toHaveBeenCalledWith(node, 'create'); + }); + + it('cannot create content for missing node', () => { + spyOn(contentService, 'hasPermission').and.returnValue(true); + + expect(component.canCreateContent(null)).toBe(false); + expect(contentService.hasPermission).not.toHaveBeenCalled(); + }); + + it('cannot create content based on permission', () => { + spyOn(contentService, 'hasPermission').and.returnValue(false); + const node: any = {}; + + expect(component.canCreateContent(node)).toBe(false); + expect(contentService.hasPermission).toHaveBeenCalledWith(node, 'create'); + }); +}); diff --git a/src/app/components/sidenav/sidenav.component.ts b/src/app/components/sidenav/sidenav.component.ts new file mode 100644 index 000000000..6f61c9151 --- /dev/null +++ b/src/app/components/sidenav/sidenav.component.ts @@ -0,0 +1,105 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Subscription } from 'rxjs/Rx'; + +import { Component, OnInit, OnDestroy } from '@angular/core'; + +import { MinimalNodeEntryEntity } from 'alfresco-js-api'; +import { AlfrescoContentService } from 'ng2-alfresco-core'; + +import { BrowsingFilesService } from '../../common/services/browsing-files.service'; + +@Component({ + selector: 'app-sidenav', + templateUrl: './sidenav.component.html', + styleUrls: ['./sidenav.component.scss'] +}) +export class SidenavComponent implements OnInit, OnDestroy { + node: MinimalNodeEntryEntity = null; + + onChangeParentSubscription: Subscription; + + constructor( + private browsingFilesService: BrowsingFilesService, + private contentService: AlfrescoContentService + ) {} + + get menus() { + const main = [ + { + icon: 'folder', + label: 'APP.BROWSE.PERSONAL.SIDENAV_LINK.LABEL', + title: 'APP.BROWSE.PERSONAL.SIDENAV_LINK.TOOLTIP', + route: { url: '/personal-files' } + }, + { + icon: 'group_work', + label: 'APP.BROWSE.LIBRARIES.SIDENAV_LINK.LABEL', + title: 'APP.BROWSE.LIBRARIES.SIDENAV_LINK.TOOLTIP', + route: { url: '/libraries' } + } + ]; + + const secondary = [ + { + icon: 'people', + label: 'APP.BROWSE.SHARED.SIDENAV_LINK.LABEL', + title: 'APP.BROWSE.SHARED.SIDENAV_LINK.TOOLTIP', + route: { url: '/shared' } + }, + { + icon: 'schedule', + label: 'APP.BROWSE.RECENT.SIDENAV_LINK.LABEL', + title: 'APP.BROWSE.RECENT.SIDENAV_LINK.TOOLTIP', + route: { url: '/recent-files' } + }, + { + icon: 'star', + label: 'APP.BROWSE.FAVORITES.SIDENAV_LINK.LABEL', + title: 'APP.BROWSE.FAVORITES.SIDENAV_LINK.TOOLTIP', + route: { url: '/favorites' } + }, + { + icon: 'delete', + label: 'APP.BROWSE.TRASHCAN.SIDENAV_LINK.LABEL', + title: 'APP.BROWSE.TRASHCAN.SIDENAV_LINK.TOOLTIP', + route: { url: '/trashcan' } + } + ]; + + return [ main, secondary ]; + } + + ngOnInit() { + this.onChangeParentSubscription = this.browsingFilesService.onChangeParent + .subscribe((node: MinimalNodeEntryEntity) => { + this.node = node; + }); + } + + ngOnDestroy() { + this.onChangeParentSubscription.unsubscribe(); + } + + canCreateContent(parentNode: MinimalNodeEntryEntity): boolean { + if (parentNode) { + return this.contentService.hasPermission(parentNode, 'create'); + } + return false; + } +} diff --git a/src/app/components/trashcan/trashcan.component.html b/src/app/components/trashcan/trashcan.component.html new file mode 100644 index 000000000..77e4a2da9 --- /dev/null +++ b/src/app/components/trashcan/trashcan.component.html @@ -0,0 +1,89 @@ +
+
+ + + + + + + + +
+ +
+ + + + + + + + + + + {{ value }} + + + + + + {{ (value || '').split('/').pop() }} + + + + + + {{ value | adfFileSize }} + + + + + + {{ value | adfTimeAgo }} + + + + + + + + + +
+
diff --git a/src/app/components/trashcan/trashcan.component.spec.ts b/src/app/components/trashcan/trashcan.component.spec.ts new file mode 100644 index 000000000..e5c8cdc96 --- /dev/null +++ b/src/app/components/trashcan/trashcan.component.spec.ts @@ -0,0 +1,77 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed, async } from '@angular/core/testing'; +import { CoreModule, AlfrescoApiService } from 'ng2-alfresco-core'; +import { TrashcanComponent } from './trashcan.component'; +import { CommonModule } from '../../common/common.module'; + +describe('TrashcanComponent', () => { + let fixture; + let component; + let alfrescoApi: AlfrescoApiService; + let page; + + beforeEach(() => { + page = { + list: { + entries: [ { entry: { id: 1 } }, { entry: { id: 2 } } ], + pagination: { data: 'data'} + } + }; + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CoreModule, + CommonModule + ], + declarations: [ + TrashcanComponent + ] + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(TrashcanComponent); + component = fixture.componentInstance; + + alfrescoApi = TestBed.get(AlfrescoApiService); + + component.documentList = { + loadTrashcan: jasmine.createSpy('loadTrashcan'), + resetSelection: jasmine.createSpy('resetSelection') + }; + }); + })); + + beforeEach(() => { + spyOn(alfrescoApi.nodesApi, 'getDeletedNodes').and.returnValue(Promise.resolve(page)); + }); + + describe('refresh()', () => { + it('calls child component to reload', () => { + component.refresh(); + expect(component.documentList.loadTrashcan).toHaveBeenCalled(); + }); + + it('calls child component to reset selection', () => { + component.refresh(); + expect(component.documentList.resetSelection).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/components/trashcan/trashcan.component.ts b/src/app/components/trashcan/trashcan.component.ts new file mode 100644 index 000000000..cb6575b2e --- /dev/null +++ b/src/app/components/trashcan/trashcan.component.ts @@ -0,0 +1,31 @@ +/*! + * @license + * Copyright 2017 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, ViewChild } from '@angular/core'; +import { DocumentListComponent } from 'ng2-alfresco-documentlist'; + +@Component({ + templateUrl: './trashcan.component.html' +}) +export class TrashcanComponent { + @ViewChild(DocumentListComponent) documentList; + + refresh(): void { + this.documentList.loadTrashcan(); + this.documentList.resetSelection(); + } +} diff --git a/src/app/ui/_layout.scss b/src/app/ui/_layout.scss new file mode 100644 index 000000000..e72873f94 --- /dev/null +++ b/src/app/ui/_layout.scss @@ -0,0 +1,68 @@ +@import './_variables.scss'; + +$app-layout--header-height: 65px; +$app-layout--side-width: 320px; + +$app-inner-layout--header-height: 48px; +$app-inner-layout--footer-height: 48px; + +.layout { + display: flex; + flex-direction: column; + flex: 1 0; + overflow: hidden; + + &__header { + flex: 0 0 $app-layout--header-height; + } + + &__content { + display: flex; + flex: 1; + flex-direction: row; + overflow: hidden; + + & > * { + display: flex; + flex: 1; + } + + &-side { + flex: 0 0 $app-layout--side-width; + background: $alfresco-gray-background; + border-right: 1px solid $alfresco-divider-color; + } + } +} + +.inner-layout { + display: flex; + flex: 1; + flex-direction: column; + + &__header, + &__footer { + display: flex; + flex: 0 0; + align-items: center; + } + + &__header { + flex-basis: $app-inner-layout--header-height; + background: $alfresco-gray-background; + border-bottom: 1px solid $alfresco-divider-color; + padding: 0 24px; + } + + &__content { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + } + + &__footer { + flex-basis: $app-inner-layout--footer-height; + border-top: 1px solid $alfresco-divider-color; + } +} diff --git a/src/app/ui/_variables-color.scss b/src/app/ui/_variables-color.scss new file mode 100644 index 000000000..b993b6936 --- /dev/null +++ b/src/app/ui/_variables-color.scss @@ -0,0 +1,48 @@ +// Primary color palette +// - please note that Hue 2 and Enhanced Hue 1 and 2 +// are missing from specs +$alfresco-app-color--default: #00bcd4; +$alfresco-app-color--hue-1: #e0f7fa; +$alfresco-app-color--hue-3: #0097a7; + +// Primary color palette - Enhanced +$alfresco-app-color--default-enhanced: #0097a7; +$alfresco-app-color--hue-3-enhanced: #006064; + +// Accent color palette +$alfresco-primary-accent--default: #ff9100; +$alfresco-primary-accent--hue-1: #ffd180; +$alfresco-primary-accent--hue-2: #ffab40; +$alfresco-primary-accent--hue-3: #ff6d00; + +$alfresco-secondary-accent--default: #3d5afe; +$alfresco-secondary-accent--hue-1: #8c9eff; +$alfresco-secondary-accent--hue-2: #536dfe; +$alfresco-secondary-accent--hue-3: #304ffe; + +// Warn color palette +$alfresco-warn-color--default: #ff1744; +$alfresco-warn-color--hue-1: #ff8a80; +$alfresco-warn-color--hue-2: #ff5252; +$alfresco-warn-color--hue-3: #d50000; + +// Grayscale +$alfresco-white: #fff; +$alfresco-black: #000; + +// Dark +$alfresco-dark-color--default: #78909c; +$alfresco-dark-color--hue-1: #eceff1; +$alfresco-dark-color--hue-3: #546e7a; + +$alfresco-drop-shadow: #888888; + +$alfresco-primary-text-color: rgba($alfresco-black, .87); +$alfresco-secondary-text-color: rgba($alfresco-black, .54); + +$alfresco-hint-text-color: rgba($alfresco-black, .38); +$alfresco-disabled-text-color: rgba($alfresco-black, .26); + +$alfresco-divider-color: rgba($alfresco-black, .07); + +$alfresco-gray-background: #fafafa; diff --git a/src/app/ui/_variables.scss b/src/app/ui/_variables.scss new file mode 100644 index 000000000..5f127abb4 --- /dev/null +++ b/src/app/ui/_variables.scss @@ -0,0 +1,3 @@ +@import './_variables-color.scss'; + +$app-menu-height: 64px; diff --git a/src/app/ui/application.scss b/src/app/ui/application.scss new file mode 100644 index 000000000..58c2fcae1 --- /dev/null +++ b/src/app/ui/application.scss @@ -0,0 +1,28 @@ +@import 'variables'; +@import 'theme'; + +html, body { + display: flex; + font-size: 14px; + font-family: "Muli", sans-serif; + color: $alfresco-primary-text-color; + overflow: hidden; + width: 100%; + height: 100%; + margin: 0; +} + +alfresco-content-app > ng-component { + display: block; + width: 100%; + height: 100%; +} + +@import 'layout'; + +@import './overrides/alfresco-document-list'; +@import './overrides/alfresco-upload-drag-area'; +@import './overrides/alfresco-upload-button'; +@import './overrides/alfresco-upload-dialog'; +@import './overrides/toolbar'; +@import './overrides/breadcrumb'; diff --git a/src/app/ui/overrides/_alfresco-document-list.scss b/src/app/ui/overrides/_alfresco-document-list.scss new file mode 100644 index 000000000..cce1777e5 --- /dev/null +++ b/src/app/ui/overrides/_alfresco-document-list.scss @@ -0,0 +1,83 @@ +@import '../_variables.scss'; + +adf-document-list { + display: flex; + flex-direction: column; + flex: 1; + overflow: auto; + + & > adf-datatable { + display: flex; + flex-direction: column; + flex: 1; + overflow: auto; + } +} + +adf-document-list .adf-data-table { + border: none !important; + + .sr-only { + display: none; + } + + tr, td { + &:focus { + outline: none !important; + } + } + + // TODO: Remove tr background-color once it gets to ADF + tr { + &:hover, &:focus { + background-color: $alfresco-app-color--hue-1; + } + + &.is-selected { + background-color: $alfresco-app-color--hue-1; + + &:hover { + background-color: $alfresco-app-color--hue-1; + } + + .image-table-cell { + position: relative; + + &:before { + content: "\E876"; /* "done" */ + font-family: "Material Icons"; + font-size: 24px; + line-height: 32px; + text-align: center; + color: white; + position: absolute; + width: 32px; + height: 32px; + top: 50%; + left: 50%; + margin-top: -16px; + margin-left: -14px; + border-radius: 100%; + background: #00bcd4; + } + } + } + + .app-name-column { + width: 100%; + + .cell-container { + max-width: 45vw; + text-overflow: ellipsis; + overflow: hidden; + } + } + } + + .adf-location-cell { + a { + text-decoration: none; + color: $alfresco-primary-text-color; + } + } +} diff --git a/src/app/ui/overrides/_alfresco-upload-button.scss b/src/app/ui/overrides/_alfresco-upload-button.scss new file mode 100644 index 000000000..19bce1e62 --- /dev/null +++ b/src/app/ui/overrides/_alfresco-upload-button.scss @@ -0,0 +1,44 @@ +@import '../_variables.scss'; + +alfresco-upload-button { + .mat-raised-button.mat-primary { + width: 100%; + border-radius: 0; + text-align: left; + line-height: 48px; + box-shadow: none; + transform: none; + transition: unset; + background-color: $alfresco-white; + } + + .mat-raised-button.mat-primary:hover:not([disabled]) { + background-color: rgba(0, 0, 0, 0.04); + } + + .mat-raised-button.mat-primary[disabled] { + background: none; + } + + .mat-raised-button.mat-primary[disabled] label { + color: rgba(0, 0, 0, 0.38); + } + + .mat-raised-button:not([disabled]):active { + box-shadow: none; + } + + md-icon { + color: rgba(0, 0, 0, 0.54); + } + + label { + text-transform: capitalize; + font-family: Muli; + font-size: 16px; + font-weight: normal; + text-align: left; + margin-left: 18px; + color: $alfresco-primary-text-color; + } +} diff --git a/src/app/ui/overrides/_alfresco-upload-dialog.scss b/src/app/ui/overrides/_alfresco-upload-dialog.scss new file mode 100644 index 000000000..85970710c --- /dev/null +++ b/src/app/ui/overrides/_alfresco-upload-dialog.scss @@ -0,0 +1,23 @@ +@import '../_variables.scss'; + +.adf-file-uploading-row { + &__status { + &--done { + color: $alfresco-app-color--default !important; + } + + &--error { + color: $alfresco-primary-accent--hue-3 !important; + } + } + + &__action { + &--cancel { + color: $alfresco-warn-color--hue-3 !important; + } + + &--remove { + color: $alfresco-dark-color--hue-3 !important; + } + } +} diff --git a/src/app/ui/overrides/_alfresco-upload-drag-area.scss b/src/app/ui/overrides/_alfresco-upload-drag-area.scss new file mode 100644 index 000000000..51138cc0b --- /dev/null +++ b/src/app/ui/overrides/_alfresco-upload-drag-area.scss @@ -0,0 +1,72 @@ +@import '../_variables.scss'; + +@mixin file-draggable__input-focus { + color: $alfresco-secondary-text-color !important; + border: 1px solid $alfresco-app-color--default !important; + margin-left: 0 !important; +} + +alfresco-upload-drag-area:first-child { + & > div { + alfresco-upload-drag-area { + .file-draggable__input-focus { + @include file-draggable__input-focus; + } + } + } + + .upload-border { + vertical-align: inherit !important; + text-align: inherit !important; + } + + .file-draggable__input-focus { + color: none !important; + border: none !important; + margin-left: 0 !important; + + alfresco-upload-drag-area { + & > div { + @include file-draggable__input-focus; + } + } + } +} + +alfresco-upload-drag-area { + height: 100%; + + & > div { + height: 100%; + display: flex; + flex-direction: column; + } + + .file-draggable__input-focus { + alfresco-document-list { + background: $alfresco-app-color--hue-1; + + alfresco-datatable > table { + background: inherit; + } + } + } + + .adf-upload__dragging { + background: $alfresco-app-color--hue-1; + color: $alfresco-secondary-text-color !important; + } + + .adf-upload__dragging td { + border-top: 1px solid $alfresco-app-color--default !important; + border-bottom: 1px solid $alfresco-app-color--default !important; + + &:first-child { + border-left: 1px solid $alfresco-app-color--default !important; + } + + &:last-child { + border-right: 1px solid $alfresco-app-color--default !important; + } + } +} diff --git a/src/app/ui/overrides/_breadcrumb.scss b/src/app/ui/overrides/_breadcrumb.scss new file mode 100644 index 000000000..0e33f840c --- /dev/null +++ b/src/app/ui/overrides/_breadcrumb.scss @@ -0,0 +1,5 @@ +@import '../variables'; + +.adf-breadcrumb { + width: 0; +} diff --git a/src/app/ui/overrides/_toolbar.scss b/src/app/ui/overrides/_toolbar.scss new file mode 100644 index 000000000..65bf17fc0 --- /dev/null +++ b/src/app/ui/overrides/_toolbar.scss @@ -0,0 +1,36 @@ +@import 'variables.scss'; + +.adf-toolbar { + // TODO: review and remove once ADF 2.0.0 is out + &.inline { + .mat-toolbar { + border-left: none !important; + border-right: none !important; + background: $alfresco-gray-background; + padding: 0; + height: 48px; + } + + // TODO remove once it gets to ADF + .mat-icon { + color: $alfresco-secondary-text-color; + } + + &-row { + & > button { + color: $alfresco-secondary-text-color; + } + } + } +} + +.secondary-options { + // TODO remove once it gets to ADF + button { + color: $alfresco-secondary-text-color; + } + + .icon-highlight { + color: $alfresco-primary-accent--default; + } +} diff --git a/src/app/ui/theme.scss b/src/app/ui/theme.scss new file mode 100644 index 000000000..4f7edb0ae --- /dev/null +++ b/src/app/ui/theme.scss @@ -0,0 +1,25 @@ +@import "~@angular/material/theming"; + +@import '~ng2-alfresco-core/styles/theming'; +@import '~ng2-alfresco-core/styles/index'; +@import '~ng2-alfresco-datatable/styles/index'; +@import '~ng2-alfresco-documentlist/styles/index'; +@import '~ng2-alfresco-login/styles/index'; +@import '~ng2-alfresco-upload/styles/index'; +@import '~ng2-alfresco-search/styles/index'; + +@include mat-core(); + +$primary: mat-palette($alfresco-accent-orange); +$accent: mat-palette($alfresco-ecm-blue); +$warn: mat-palette($alfresco-warn); +$theme: mat-light-theme($primary, $accent, $warn); + +@include angular-material-theme($theme); + +@include alfresco-core-theme($theme); +@include alfresco-datatable-theme($theme); +@include alfresco-documentlist-theme($theme); +@include alfresco-login-theme($theme); +@include alfresco-upload-theme($theme); +@include alfresco-search-theme($theme); diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json new file mode 100644 index 000000000..255a3ba6b --- /dev/null +++ b/src/assets/i18n/en.json @@ -0,0 +1,201 @@ +{ + "APP": { + "SIGN_IN": "Sign in", + "SIGN_OUT": "Sign out", + "NEW_MENU": { + "LABEL": "New", + "MENU_ITEMS": { + "CREATE_FOLDER": "Create folder", + "UPLOAD_FILE": "Upload file", + "UPLOAD_FOLDER": "Upload folder" + }, + "TOOLTIPS": { + "CREATE_FOLDER": "Create new folder", + "CREATE_FOLDER_NOT_ALLOWED": "You can't create a folder here. You might not have the required permissions, check with your IT Team.", + "UPLOAD_FILES": "Select files to upload", + "UPLOAD_FILES_NOT_ALLOWED": "You need permissions to upload here, check with your IT Team.", + "UPLOAD_FOLDERS": "Select folders to upload", + "UPLOAD_FOLDERS_NOT_ALLOWED": "You need permissions to upload here, check with your IT Team." + } + }, + "BROWSE": { + "PERSONAL": { + "TITLE": "Personal Files", + "SIDENAV_LINK": { + "LABEL": "Personal Files", + "TOOLTIP": "View your Personal Files" + } + }, + "LIBRARIES": { + "TITLE": "File Libraries", + "SIDENAV_LINK": { + "LABEL": "File Libraries", + "TOOLTIP": "Access File Libraries" + } + }, + "SHARED": { + "TITLE": "Shared Files", + "SIDENAV_LINK": { + "LABEL": "Shared", + "TOOLTIP": "View files that have been shared" + } + }, + "RECENT": { + "TITLE": "Recent Files", + "SIDENAV_LINK": { + "LABEL": "Recent Files", + "TOOLTIP": "View files you recently edited" + } + }, + "FAVORITES": { + "TITLE": "Favorites", + "SIDENAV_LINK": { + "LABEL": "Favorites", + "TOOLTIP": "View your favorite files and folders" + } + }, + "TRASHCAN": { + "TITLE": "Trash", + "SIDENAV_LINK": { + "LABEL": "Trash", + "TOOLTIP": "View deleted files in the trash" + } + } + }, + "ACTIONS": { + "VIEW": "View", + "EDIT": "Edit", + "DOWNLOAD": "Download", + "COPY": "Copy", + "MOVE": "Move", + "DELETE": "Delete", + "MORE": "More actions", + "UNDO": "Undo", + "RESTORE": "Restore", + "FAVORITE": "Favorite" + }, + "DOCUMENT_LIST": { + "COLUMNS": { + "NAME": "Name", + "SIZE": "Size", + "MODIFIED_ON": "Modified", + "MODIFIED_BY": "Modified by", + "STATUS": "Status", + "TITLE": "Title", + "LOCATION": "Location", + "SHARED_BY": "Shared by", + "DELETED_ON": "Deleted", + "DELETED_BY": "Deleted by" + } + }, + "DIALOG": { + "MINIMIZE": "Minimize", + "MAXIMIZE": "Maximize", + "CLOSE": "Close", + "UPLOAD": { + "IN_PROGRESS": "Uploading {{ completed }} / {{ length }}", + "COMPLETE": "Uploaded {{ completed }} / {{ length }}", + "CANCELED": "Upload canceled", + "CANCEL_UPLOAD": "Cancel upload", + "CANCEL_ALL": "Cancel uploads", + "REMOVE_UPLOADED": "Remove uploaded file", + "CANCELED_STATUS": "Canceled", + "ERRORS": "error(s)" + } + }, + "FOLDER_DIALOG": { + "CREATE_FOLDER_TITLE": "Create new folder", + "EDIT_FOLDER_TITLE": "Edit folder", + "FOLDER_NAME": { + "LABEL": "Name", + "ERRORS": { + "REQUIRED": "Folder name is required", + "SPECIAL_CHARACTERS": "Folder name can't contain these characters * \" < > \\ / ? : |", + "ENDING_DOT": "Folder name can't end with a period .", + "ONLY_SPACES": "Folder name can't contain only spaces" + } + }, + "FOLDER_DESCRIPTION": { + "LABEL": "Description" + }, + "CREATE_BUTTON": { + "LABEL": "Create" + }, + "UPDATE_BUTTON": { + "LABEL": "Update" + }, + "CANCEL_BUTTON": { + "LABEL": "Cancel" + } + }, + "PAGINATION": { + "ITEMS_PER_PAGE": "Items per page", + "CURRENT_PAGE": "Page", + "OF": "of", + "SHOW_RANGE": "Showing {{ range }} of {{ total }} items" + }, + "SITES_VISIBILITY": { + "PUBLIC": "Public", + "MODERATED": "Moderated", + "PRIVATE": "Private" + }, + "MESSAGES": { + "ERRORS":{ + "GENERIC": "The action was unsuccessful. Try again or contact your IT Team.", + "CONFLICT": "This name is already in use, try a different name.", + "NODE_MOVE": "Move unsuccessful, a file with the same name already exists.", + "EXISTENT_FOLDER": "There's already a folder with this name. Try a different name.", + "NODE_DELETION": "{{ name }} couldn't be deleted", + "NODE_DELETION_PLURAL": "{{ number }} items couldn't be deleted", + "NODE_RESTORE": "{{ name }} couldn't be restored", + "NODE_RESTORE_PLURAL": "{{ number }} items couldn't be restored", + "PERMISSION": "You don't have access to do this", + "TRASH": { + "NODES_PURGE": { + "PLURAL": "{{ number }} items couldn't be deleted", + "SINGULAR": "{{ name }} item couldn't be deleted" + }, + "NODES_RESTORE": { + "PARTIAL_PLURAL": "{{ number }} items not restored because of issues with the restore location", + "NODE_EXISTS": "Can't restore, {{ name }} item already exists", + "LOCATION_MISSING": "Can't restore {{ name }} item, the original location no longer exists", + "GENERIC": "There was a problem restoring {{ name }} item" + } + } + }, + "INFO": { + "TRASH": { + "NODES_PURGE": { + "PLURAL": "{{ number }} items deleted", + "SINGULAR": "{{ name }} item deleted", + "PARTIAL_SINGULAR": "{{ name }} item deleted, {{ failed }} couldn't be deleted", + "PARTIAL_PLURAL": "{{ number }} items deleted, {{ failed }} couldn't be deleted" + }, + "NODES_RESTORE": { + "PLURAL": "Restore successful", + "SINGULAR": "{{ name }} item restored" + } + }, + "NODE_DELETION": { + "SINGULAR": "{{ name }} deleted", + "PLURAL": "Deleted {{ number }} items", + "PARTIAL_SINGULAR": "Deleted {{ success }} item, {{ failed }} couldn't be deleted", + "PARTIAL_PLURAL": "Deleted {{ success }} items, {{ failed }} couldn't be deleted" + }, + "NODE_COPY": { + "SINGULAR": "Copied {{ number }} item", + "PLURAL": "Copied {{ number }} items" + }, + "NODE_MOVE": { + "SINGULAR": "Moved {{ success }} item.", + "PLURAL": "Moved {{ success }} items.", + "PARTIAL": { + "SINGULAR": "Partially moved {{ partially }} item.", + "PLURAL": "Partially moved {{ partially }} items.", + "FAIL": "{{ failed }} couldn't be moved." + } + } + } + } + } +} diff --git a/src/assets/i18n/ru.json b/src/assets/i18n/ru.json new file mode 100644 index 000000000..141d2cf52 --- /dev/null +++ b/src/assets/i18n/ru.json @@ -0,0 +1,125 @@ +{ + "APP": { + "SIGN_IN": "russian Sign in", + "SIGN_OUT": "russian Sign out", + "NEW_MENU": { + "TITLE": "russian New", + "MENU_ITEMS": { + "UPLOAD_FILE": "russian Upload file", + "UPLOAD_FOLDER": "russian Upload folder" + } + }, + "BROWSE": { + "PERSONAL": { + "TITLE": "Личные файлы", + "SIDENAV_LINK": { + "LABEL": "Личные файлы", + "TOOLTIP": "russian personal tooltip" + } + }, + "LIBRARIES": { + "TITLE": "russian libraries title", + "SIDENAV_LINK": { + "LABEL": "russian libraries label", + "TOOLTIP": "russian libraries tooltip" + } + }, + "SHARED": { + "TITLE": "russian shared title", + "SIDENAV_LINK": { + "LABEL": "russian shared label", + "TOOLTIP": "russian shared tooltip" + } + }, + "RECENT": { + "TITLE": "Недавние файлы", + "SIDENAV_LINK": { + "LABEL": "Недавние файлы", + "TOOLTIP": "russian recent tooltip" + } + }, + "FAVORITES": { + "TITLE": "russian favorites title", + "SIDENAV_LINK": { + "LABEL": "russian favorites label", + "TOOLTIP": "russian favorites tooltip" + } + }, + "TRASHCAN": { + "TITLE": "russian trashcan title", + "SIDENAV_LINK": { + "LABEL": "russian trashcan label", + "TOOLTIP": "russian trashcan tooltip" + } + } + }, + "ACTIONS": { + "VIEW": "Просмотр", + "SHARE": "Поделиться", + "DOWNLOAD": "Скачать", + "COPY": "Копировать", + "MOVE": "Переместить", + "DELETE": "Удалить", + "MORE": "russian More actions" + }, + "DOCUMENT_LIST": { + "COLUMNS": { + "NAME": "russian Name", + "SIZE": "russian Size", + "MODIFIED_ON": "russian Modified", + "MODIFIED_BY": "russian Modified by", + "STATUS": "russian Status", + "TITLE": "russian Title", + "LOCATION": "russian Location", + "SHARED_BY": "russian Shared by", + "DELETED_ON": "russian Deleted", + "DELETED_BY": "russian Deleted by" + } + }, + "DIALOG": { + "MINIMIZE": "russian Minimize", + "MAXIMIZE": "russian Maximize", + "CLOSE": "russian Close", + "UPLOAD": { + "IN_PROGRESS": "russian Uploading {{ completed }} / {{ length }}", + "CANCELED": "russian Upload canceled", + "CANCEL_UPLOAD": "russian Cancel upload", + "CANCEL_ALL": "russian Cancel uploads", + "REMOVE_UPLOADED": "russian Remove uploaded file", + "CANCELED_STATUS": "russian Canceled" + } + }, + "PAGINATION": { + "ITEMS_PER_PAGE": "russian Items per page", + "CURRENT_PAGE": "russian Page", + "OF": "russian of", + "SHOW_RANGE": "russian Showing {{ range }} of {{ total }} items" + }, + "PIPES": { + "DATE": { + "NOW": "russian just now", + "1_MINUTE_AGO": "russian 1 minute ago", + "MINUTES_AGO": "russian {{minutes }} minutes ago", + "1_HOUR_AGO": "russian 1 hour ago", + "HOURS_AGO": "russian {{ hours }} hours ago", + "1_DAY_AGO": "russian 1 day ago", + "DAYS_AGO": "russian {{ days }} days ago" + }, + "SIZE": { + "UNITS": { + "BYTE": "russian B", + "KILOBYTE": "russian kB", + "MEGABYTE": "russian MB", + "GIGABYTE": "russian GB", + "TERABYTE": "russian TB" + }, + "BYTES": "{{ bytes }} russian bytes" + } + }, + "SITES_VISIBILITY": { + "PUBLIC": "russian Public", + "MODERATED": "russian Moderated", + "PRIVATE": "russian Private" + } + } +} diff --git a/src/assets/images/alfresco-logo-white.svg b/src/assets/images/alfresco-logo-white.svg new file mode 100644 index 000000000..6ec673eb3 --- /dev/null +++ b/src/assets/images/alfresco-logo-white.svg @@ -0,0 +1,129 @@ + +image/svg+xml \ No newline at end of file diff --git a/src/styles.css b/src/styles.scss similarity index 91% rename from src/styles.css rename to src/styles.scss index c4a5b46f9..d71979366 100644 --- a/src/styles.css +++ b/src/styles.scss @@ -1,6 +1,7 @@ /* You can add global styles to this file, and also import other style files */ @import '~@angular/material/prebuilt-themes/deeppurple-amber.css'; @import '~ng2-alfresco-core/prebuilt-themes/adf-blue-orange.css'; +@import 'app/ui/application'; body, html { height: 100%;