diff --git a/demo-shell-ng2/app/components/files/custom-sources.component.html b/demo-shell-ng2/app/components/files/custom-sources.component.html index cff8d59ede..e3cf002dc6 100644 --- a/demo-shell-ng2/app/components/files/custom-sources.component.html +++ b/demo-shell-ng2/app/components/files/custom-sources.component.html @@ -6,8 +6,20 @@ + +
+ +
+ locationFormat="/files" + selectionMode="multiple"> diff --git a/demo-shell-ng2/app/components/files/custom-sources.component.ts b/demo-shell-ng2/app/components/files/custom-sources.component.ts index d047a65729..52ceef4d15 100644 --- a/demo-shell-ng2/app/components/files/custom-sources.component.ts +++ b/demo-shell-ng2/app/components/files/custom-sources.component.ts @@ -15,7 +15,8 @@ * limitations under the License. */ -import { Component, Input } from '@angular/core'; +import { Component, Input, ViewChild } from '@angular/core'; +import { DocumentListComponent } from 'ng2-alfresco-documentlist'; @Component({ selector: 'adf-custom-sources-demo', @@ -26,6 +27,9 @@ export class CustomSourcesComponent { @Input() selectedSource = '-recent-'; + @ViewChild(DocumentListComponent) + documentList: DocumentListComponent; + sources = [ { title: 'Favorites', value: '-favorites-' }, { title: 'Recent', value: '-recent-' }, diff --git a/docIndex.md b/docIndex.md index 7c3bebe61a..23a5b80103 100644 --- a/docIndex.md +++ b/docIndex.md @@ -315,6 +315,7 @@ for more information about installing and using the source code. - [Context menu directive](docs/context-menu.directive.md) - [Logout directive](docs/logout.directive.md) +- [Node restore directive](docs/node-restore.directive.md) - [Node permission directive](docs/node-permission.directive.md) - [Node favorite directive](docs/node-favorite.directive.md) - [Upload directive](docs/upload.directive.md) diff --git a/docs/node-restore.directive.md b/docs/node-restore.directive.md new file mode 100644 index 0000000000..80cf391891 --- /dev/null +++ b/docs/node-restore.directive.md @@ -0,0 +1,52 @@ +# Node Restore directive + + + + + +- [Basic Usage](#basic-usage) + * [Properties](#properties) + * [Events](#events) +- [Details](#details) + + + + + +## Basic Usage + +```html + + + + + + ... + +``` + +### Properties + +| Name | Type | Default | Description | +| ----------------- | ------------------- | ------- | ------------------------------- | +| adf-restore | DeletedNodeEntry[] | [] | Deleted nodes to restore | +| location | string | '' | Route path to view restored node | + +### Events + +| Name | Description | +| --------- | ------------------------------- | +| restore | Raised when the restore is done | + +## Details + +'NodeRestoreDirective' directive takes a selection of `DeletedNodeEntry[]` and restores them in their original location. +If the original location doesn't exist anymore, then they remain in the trash list. + +For single node restore, there is action to jump to the location where the node has been restored and for this `location` is used to specify the route path where the list of nodes are rendered diff --git a/ng2-components/ng2-alfresco-core/index.ts b/ng2-components/ng2-alfresco-core/index.ts index e20c675848..0462c75648 100644 --- a/ng2-components/ng2-alfresco-core/index.ts +++ b/ng2-components/ng2-alfresco-core/index.ts @@ -122,6 +122,7 @@ import { } from './src/components/info-drawer/info-drawer-layout.component'; import { InfoDrawerComponent, InfoDrawerTabComponent } from './src/components/info-drawer/info-drawer.component'; import { NodePermissionDirective } from './src/directives/node-permission.directive'; +import { NodeRestoreDirective } from './src/directives/node-restore.directive'; import { UploadDirective } from './src/directives/upload.directive'; import { FileSizePipe } from './src/pipes/file-size.pipe'; @@ -141,6 +142,7 @@ export * from './src/components/data-column/data-column-list.component'; export * from './src/components/info-drawer/info-drawer.component'; export * from './src/directives/upload.directive'; export * from './src/directives/highlight.directive'; +export * from './src/directives/node-restore.directive'; export * from './src/directives/node-permission.directive'; export * from './src/directives/node-favorite.directive'; export * from './src/utils/index'; @@ -248,6 +250,7 @@ export function createTranslateLoader(http: Http, logService: LogService) { ...pipes(), LogoutDirective, UploadDirective, + NodeRestoreDirective, NodePermissionDirective, NodeFavoriteDirective, HighlightDirective, @@ -291,6 +294,7 @@ export function createTranslateLoader(http: Http, logService: LogService) { ...pipes(), LogoutDirective, UploadDirective, + NodeRestoreDirective, NodePermissionDirective, NodeFavoriteDirective, HighlightDirective, diff --git a/ng2-components/ng2-alfresco-core/src/directives/node-restore.directive.spec.ts b/ng2-components/ng2-alfresco-core/src/directives/node-restore.directive.spec.ts new file mode 100644 index 0000000000..9aa655b4a2 --- /dev/null +++ b/ng2-components/ng2-alfresco-core/src/directives/node-restore.directive.spec.ts @@ -0,0 +1,340 @@ +/*! + * @license + * Copyright 2016 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 { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Observable } from 'rxjs/Rx'; +import { AlfrescoTranslationService } from '../../index'; +import { CoreModule } from '../../index'; +import { AlfrescoApiService } from '../services/alfresco-api.service'; +import { NotificationService } from '../services/notification.service'; +import { NodeRestoreDirective } from './node-restore.directive'; + +@Component({ + template: ` +
+
` +}) +class TestComponent { + selection = []; + + done = jasmine.createSpy('done'); +} + +describe('NodeRestoreDirective', () => { + let fixture: ComponentFixture; + let element: DebugElement; + let component: TestComponent; + let alfrescoService: AlfrescoApiService; + let translation: AlfrescoTranslationService; + let notification: NotificationService; + let router: Router; + let nodesService; + let coreApi; + let directiveInstance; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CoreModule, + RouterTestingModule + ], + declarations: [ + TestComponent + ] + }) + .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(AlfrescoTranslationService); + notification = TestBed.get(NotificationService); + router = TestBed.get(Router); + }); + })); + + beforeEach(() => { + spyOn(translation, 'get').and.returnValue(Observable.of('message')); + }); + + it('should not restore when selection is empty', () => { + spyOn(nodesService, 'restoreNode'); + + component.selection = []; + + fixture.detectChanges(); + element.triggerEventHandler('click', null); + + expect(nodesService.restoreNode).not.toHaveBeenCalled(); + }); + + it('should not restore nodes when 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('should call restore when 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('should 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('should reset status', fakeAsync(() => { + directiveInstance.restoreProcessStatus.fail = [{}]; + directiveInstance.restoreProcessStatus.success = [{}]; + + directiveInstance.restoreProcessStatus.reset(); + + expect(directiveInstance.restoreProcessStatus.fail).toEqual([]); + expect(directiveInstance.restoreProcessStatus.success).toEqual([]); + })); + + it('should emit 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(component.done).toHaveBeenCalled(); + })); + }); + + describe('notification', () => { + beforeEach(() => { + spyOn(coreApi.nodesApi, 'getDeletedNodes').and.returnValue(Promise.resolve({ + list: { entries: [] } + })); + }); + + it('should notify on multiple fails', fakeAsync(() => { + const error = { message: '{ "error": {} }' }; + + spyOn(notification, '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( + 'CORE.RESTORE_NODE.PARTIAL_PLURAL', + { number: 2 } + ); + })); + + it('should notify fail when restored node exist, error 409', fakeAsync(() => { + const error = { message: '{ "error": { "statusCode": 409 } }' }; + + spyOn(notification, '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( + 'CORE.RESTORE_NODE.NODE_EXISTS', + { name: 'name1' } + ); + })); + + it('should notify fail when restored node returns different statusCode', fakeAsync(() => { + const error = { message: '{ "error": { "statusCode": 404 } }' }; + + spyOn(notification, '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( + 'CORE.RESTORE_NODE.GENERIC', + { name: 'name1' } + ); + })); + + it('should notify fail when restored node location is missing', fakeAsync(() => { + const error = { message: '{ "error": { } }' }; + + spyOn(notification, '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( + 'CORE.RESTORE_NODE.LOCATION_MISSING', + { name: 'name1' } + ); + })); + + it('should notify success when restore multiple nodes', fakeAsync(() => { + spyOn(notification, '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( + 'CORE.RESTORE_NODE.PLURAL' + ); + })); + + it('should notify success on restore selected node', fakeAsync(() => { + spyOn(notification, '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( + 'CORE.RESTORE_NODE.SINGULAR', + { name: 'name1' } + ); + })); + + it('should navigate to restored node location onAction', fakeAsync(() => { + spyOn(router, 'navigate'); + spyOn(nodesService, 'restoreNode').and.returnValue(Promise.resolve()); + spyOn(notification, '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/ng2-components/ng2-alfresco-core/src/directives/node-restore.directive.ts b/ng2-components/ng2-alfresco-core/src/directives/node-restore.directive.ts new file mode 100644 index 0000000000..5268ccd006 --- /dev/null +++ b/ng2-components/ng2-alfresco-core/src/directives/node-restore.directive.ts @@ -0,0 +1,262 @@ +/*! + * @license + * Copyright 2016 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, EventEmitter, HostListener, Input, Output } from '@angular/core'; +import { Router } from '@angular/router'; +import { DeletedNodeEntry, PathInfoEntity } from 'alfresco-js-api'; +import { Observable } from 'rxjs/Rx'; +import { AlfrescoApiService } from '../services/alfresco-api.service'; +import { NotificationService } from '../services/notification.service'; +import { TranslationService } from '../services/translation.service'; + +@Directive({ + selector: '[adf-restore]' +}) +export class NodeRestoreDirective { + private restoreProcessStatus; + + @Input('adf-restore') + selection: DeletedNodeEntry[]; + + @Input() location: string = ''; + + @Output() restore: EventEmitter = new EventEmitter(); + + @HostListener('click') + onClick() { + this.recover(this.selection); + } + + constructor( + private alfrescoApiService: AlfrescoApiService, + private translation: TranslationService, + private router: Router, + private notification: NotificationService + ) { + this.restoreProcessStatus = this.processStatus(); + } + + private recover(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.recover(remainingNodes); + } + } + ); + } + + private restoreNodesBatch(batch: DeletedNodeEntry[]): Observable { + return Observable.forkJoin(batch.map((node) => this.restoreNode(node))); + } + + private getNodesWithPath(selection): DeletedNodeEntry[] { + 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([ this.location, 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( + 'CORE.RESTORE_NODE.PARTIAL_PLURAL', + { + number: status.fail.length + } + ); + } + + if (status.oneFailed && status.fail[0].statusCode) { + if (status.fail[0].statusCode === 409) { + return this.translation.get( + 'CORE.RESTORE_NODE.NODE_EXISTS', + { + name: status.fail[0].entry.name + } + ); + } else { + return this.translation.get( + 'CORE.RESTORE_NODE.GENERIC', + { + name: status.fail[0].entry.name + } + ); + } + } + + if (status.oneFailed && !status.fail[0].statusCode) { + return this.translation.get( + 'CORE.RESTORE_NODE.LOCATION_MISSING', + { + name: status.fail[0].entry.name + } + ); + } + + if (status.allSucceeded && !status.oneSucceeded) { + return this.translation.get('CORE.RESTORE_NODE.PLURAL'); + } + + if (status.allSucceeded && status.oneSucceeded) { + return this.translation.get( + 'CORE.RESTORE_NODE.SINGULAR', + { + name: status.success[0].entry.name + } + ); + } + } + + private restoreNotification(): void { + const status = Object.assign({}, this.restoreProcessStatus); + + Observable.zip( + this.getRestoreMessage(), + this.translation.get('CORE.RESTORE_NODE.VIEW') + ).subscribe((messages) => { + const [ message, actionLabel ] = messages; + const action = (status.oneSucceeded && !status.someFailed) ? actionLabel : ''; + + this.notification.openSnackMessageAction(message, action) + .onAction() + .subscribe(() => this.navigateLocation(status.success[0].entry.path)); + }); + } + + private refresh(): void { + this.restoreProcessStatus.reset(); + this.selection = []; + this.restore.emit(); + } +} diff --git a/ng2-components/ng2-alfresco-core/src/i18n/en.json b/ng2-components/ng2-alfresco-core/src/i18n/en.json index da9d202649..86c7663bf6 100644 --- a/ng2-components/ng2-alfresco-core/src/i18n/en.json +++ b/ng2-components/ng2-alfresco-core/src/i18n/en.json @@ -13,6 +13,15 @@ }, "TITLE": "Adding files to zip, this could take a few minutes" } + }, + "RESTORE_NODE": { + "VIEW": "View", + "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", + "PLURAL": "Restore successful", + "SINGULAR": "{{ name }} item restored" } } }