From 7509095d200e66d802f38e8a069503bcb9c517b2 Mon Sep 17 00:00:00 2001 From: Denys Vuika Date: Mon, 23 Jul 2018 09:39:06 +0100 Subject: [PATCH] extensions: purge and delete toolbar actions (#528) * rework "purge" action * "restore deleted" action * fix tests * cleanup comments * allow inline action names * allow inline rules without params * simplify bulk registration --- .../node-permanent-delete.directive.ts | 34 +- .../directives/node-restore.directive.spec.ts | 136 ++++++-- src/app/directives/node-restore.directive.ts | 214 +----------- src/app/extensions/core.extensions.module.ts | 42 +-- .../extensions/evaluators/app.evaluators.ts | 5 + src/app/extensions/extension.service.ts | 41 ++- .../services/content-management.service.ts | 321 +++++++++++++++++- src/app/store/actions/node.actions.ts | 4 +- src/app/store/effects/node.effects.ts | 125 ++----- src/assets/app.extensions.json | 65 ++-- 10 files changed, 565 insertions(+), 422 deletions(-) diff --git a/src/app/directives/node-permanent-delete.directive.ts b/src/app/directives/node-permanent-delete.directive.ts index 459f54360..b32b5e895 100644 --- a/src/app/directives/node-permanent-delete.directive.ts +++ b/src/app/directives/node-permanent-delete.directive.ts @@ -25,13 +25,9 @@ import { Directive, HostListener, Input } from '@angular/core'; import { MinimalNodeEntity } from 'alfresco-js-api'; -import { MatDialog } from '@angular/material'; -import { ConfirmDialogComponent } from '@alfresco/adf-content-services'; import { Store } from '@ngrx/store'; - -import { AppStore } from '../store/states/app.state'; +import { AppStore } from '../store/states'; import { PurgeDeletedNodesAction } from '../store/actions'; -import { NodeInfo } from '../store/models'; @Directive({ selector: '[acaPermanentDelete]' @@ -43,35 +39,11 @@ export class NodePermanentDeleteDirective { selection: MinimalNodeEntity[]; constructor( - private store: Store, - private dialog: MatDialog + private store: Store ) {} @HostListener('click') onClick() { - const dialogRef = this.dialog.open(ConfirmDialogComponent, { - data: { - title: 'APP.DIALOGS.CONFIRM_PURGE.TITLE', - message: 'APP.DIALOGS.CONFIRM_PURGE.MESSAGE', - yesLabel: 'APP.DIALOGS.CONFIRM_PURGE.YES_LABEL', - noLabel: 'APP.DIALOGS.CONFIRM_PURGE.NO_LABEL' - }, - minWidth: '250px' - }); - - dialogRef.afterClosed().subscribe(result => { - if (result === true) { - const nodesToDelete: NodeInfo[] = this.selection.map(node => { - const { name } = node.entry; - const id = node.entry.nodeId || node.entry.id; - - return { - id, - name - }; - }); - this.store.dispatch(new PurgeDeletedNodesAction(nodesToDelete)); - } - }); + this.store.dispatch(new PurgeDeletedNodesAction(this.selection)); } } diff --git a/src/app/directives/node-restore.directive.spec.ts b/src/app/directives/node-restore.directive.spec.ts index 2380f8fb7..02d85912b 100644 --- a/src/app/directives/node-restore.directive.spec.ts +++ b/src/app/directives/node-restore.directive.spec.ts @@ -28,7 +28,7 @@ import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testin import { By } from '@angular/platform-browser'; import { NodeRestoreDirective } from './node-restore.directive'; import { ContentManagementService } from '../services/content-management.service'; -import { Actions, ofType } from '@ngrx/effects'; +import { Actions, ofType, EffectsModule } from '@ngrx/effects'; import { SnackbarErrorAction, SNACKBAR_ERROR, SnackbarInfoAction, SNACKBAR_INFO, NavigateRouteAction, NAVIGATE_ROUTE } from '../store/actions'; @@ -36,26 +36,30 @@ import { map } from 'rxjs/operators'; import { AppTestingModule } from '../testing/app-testing.module'; import { ContentApiService } from '../services/content-api.service'; import { Observable } from 'rxjs/Rx'; +import { NodeEffects } from '../store/effects'; +import { MinimalNodeEntity } from 'alfresco-js-api'; @Component({ template: `
` }) class TestComponent { - selection = []; + selection: Array = []; } describe('NodeRestoreDirective', () => { let fixture: ComponentFixture; let element: DebugElement; let component: TestComponent; - let directiveInstance: NodeRestoreDirective; let contentManagementService: ContentManagementService; let actions$: Actions; let contentApi: ContentApiService; beforeEach(() => { TestBed.configureTestingModule({ - imports: [ AppTestingModule ], + imports: [ + AppTestingModule, + EffectsModule.forRoot([NodeEffects]) + ], declarations: [ NodeRestoreDirective, TestComponent @@ -67,7 +71,6 @@ describe('NodeRestoreDirective', () => { fixture = TestBed.createComponent(TestComponent); component = fixture.componentInstance; element = fixture.debugElement.query(By.directive(NodeRestoreDirective)); - directiveInstance = element.injector.get(NodeRestoreDirective); contentManagementService = TestBed.get(ContentManagementService); contentApi = TestBed.get(ContentApiService); @@ -96,13 +99,28 @@ describe('NodeRestoreDirective', () => { }); it('call restore nodes if selection has nodes with path', fakeAsync(() => { - spyOn(directiveInstance, 'restoreNotification').and.callFake(() => null); spyOn(contentApi, 'restoreNode').and.returnValue(Observable.of({})); spyOn(contentApi, 'getDeletedNodes').and.returnValue(Observable.of({ list: { entries: [] } })); - component.selection = [{ entry: { id: '1', path: ['somewhere-over-the-rainbow'] } }]; + const path = { + elements: [ + { + id: '1-1', + name: 'somewhere-over-the-rainbow' + } + ] + }; + + component.selection = [ + { + entry: { + id: '1', + path + } + } + ]; fixture.detectChanges(); element.triggerEventHandler('click', null); @@ -113,13 +131,28 @@ describe('NodeRestoreDirective', () => { describe('refresh()', () => { it('dispatch event on finish', fakeAsync(done => { - spyOn(directiveInstance, 'restoreNotification').and.callFake(() => null); spyOn(contentApi, 'restoreNode').and.returnValue(Observable.of({})); spyOn(contentApi, 'getDeletedNodes').and.returnValue(Observable.of({ list: { entries: [] } })); - component.selection = [{ entry: { id: '1', path: ['somewhere-over-the-rainbow'] } }]; + const path = { + elements: [ + { + id: '1-1', + name: 'somewhere-over-the-rainbow' + } + ] + }; + + component.selection = [ + { + entry: { + id: '1', + path + } + } + ]; fixture.detectChanges(); element.triggerEventHandler('click', null); @@ -158,10 +191,19 @@ describe('NodeRestoreDirective', () => { } }); + const path = { + elements: [ + { + id: '1-1', + name: 'somewhere-over-the-rainbow' + } + ] + }; + 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'] } } + { entry: { id: '1', name: 'name1', path } }, + { entry: { id: '2', name: 'name2', path } }, + { entry: { id: '3', name: 'name3', path } } ]; fixture.detectChanges(); @@ -178,8 +220,17 @@ describe('NodeRestoreDirective', () => { map(action => done()) ); + const path = { + elements: [ + { + id: '1-1', + name: 'somewhere-over-the-rainbow' + } + ] + }; + component.selection = [ - { entry: { id: '1', name: 'name1', path: ['somewhere-over-the-rainbow'] } } + { entry: { id: '1', name: 'name1', path } } ]; fixture.detectChanges(); @@ -197,8 +248,17 @@ describe('NodeRestoreDirective', () => { map(action => done()) ); + const path = { + elements: [ + { + id: '1-1', + name: 'somewhere-over-the-rainbow' + } + ] + }; + component.selection = [ - { entry: { id: '1', name: 'name1', path: ['somewhere-over-the-rainbow'] } } + { entry: { id: '1', name: 'name1', path } } ]; fixture.detectChanges(); @@ -216,8 +276,17 @@ describe('NodeRestoreDirective', () => { map(action => done()) ); + const path = { + elements: [ + { + id: '1-1', + name: 'somewhere-over-the-rainbow' + } + ] + }; + component.selection = [ - { entry: { id: '1', name: 'name1', path: ['somewhere-over-the-rainbow'] } } + { entry: { id: '1', name: 'name1', path } } ]; fixture.detectChanges(); @@ -241,9 +310,18 @@ describe('NodeRestoreDirective', () => { map(action => done()) ); + const path = { + elements: [ + { + id: '1-1', + name: 'somewhere-over-the-rainbow' + } + ] + }; + component.selection = [ - { entry: { id: '1', name: 'name1', path: ['somewhere-over-the-rainbow'] } }, - { entry: { id: '2', name: 'name2', path: ['somewhere-over-the-rainbow'] } } + { entry: { id: '1', name: 'name1', path } }, + { entry: { id: '2', name: 'name2', path } } ]; fixture.detectChanges(); @@ -259,8 +337,17 @@ describe('NodeRestoreDirective', () => { map(action => done()) ); + const path = { + elements: [ + { + id: '1-1', + name: 'somewhere-over-the-rainbow' + } + ] + }; + component.selection = [ - { entry: { id: '1', name: 'name1', path: ['somewhere-over-the-rainbow'] } } + { entry: { id: '1', name: 'name1', path } } ]; fixture.detectChanges(); @@ -276,14 +363,21 @@ describe('NodeRestoreDirective', () => { map(action => done()) ); + const path = { + elements: [ + { + id: '1-1', + name: 'somewhere-over-the-rainbow' + } + ] + }; + component.selection = [ { entry: { id: '1', name: 'name1', - path: { - elements: ['somewhere-over-the-rainbow'] - } + path } } ]; diff --git a/src/app/directives/node-restore.directive.ts b/src/app/directives/node-restore.directive.ts index 217920900..4380d6c34 100644 --- a/src/app/directives/node-restore.directive.ts +++ b/src/app/directives/node-restore.directive.ts @@ -24,25 +24,10 @@ */ import { Directive, HostListener, Input } from '@angular/core'; -import { Observable } from 'rxjs/Rx'; -import { - MinimalNodeEntity, - MinimalNodeEntryEntity, - PathInfoEntity, - DeletedNodesPaging -} from 'alfresco-js-api'; -import { DeleteStatus, DeletedNodeInfo } from '../store/models'; -import { ContentManagementService } from '../services/content-management.service'; +import { MinimalNodeEntity } from 'alfresco-js-api'; import { Store } from '@ngrx/store'; -import { AppStore } from '../store/states/app.state'; -import { - NavigateRouteAction, - SnackbarAction, - SnackbarErrorAction, - SnackbarInfoAction, - SnackbarUserAction -} from '../store/actions'; -import { ContentApiService } from '../services/content-api.service'; +import { AppStore } from '../store/states'; +import { RestoreDeletedNodesAction } from '../store/actions'; @Directive({ selector: '[acaRestoreNode]' @@ -51,197 +36,10 @@ export class NodeRestoreDirective { // tslint:disable-next-line:no-input-rename @Input('acaRestoreNode') selection: MinimalNodeEntity[]; + constructor(private store: Store) {} + @HostListener('click') onClick() { - this.restore(this.selection); - } - - constructor( - private store: Store, - private contentApi: ContentApiService, - private contentManagementService: ContentManagementService - ) {} - - private restore(selection: MinimalNodeEntity[] = []) { - if (!selection.length) { - return; - } - - const nodesWithPath = selection.filter(node => node.entry.path); - - if (selection.length && !nodesWithPath.length) { - const failedStatus = this.processStatus([]); - failedStatus.fail.push(...selection); - this.restoreNotification(failedStatus); - this.refresh(); - return; - } - - let status: DeleteStatus; - - Observable.forkJoin(nodesWithPath.map(node => this.restoreNode(node))) - .do(restoredNodes => { - status = this.processStatus(restoredNodes); - }) - .flatMap(() => this.contentApi.getDeletedNodes()) - .subscribe((nodes: DeletedNodesPaging) => { - const selectedNodes = this.diff(status.fail, selection, false); - const remainingNodes = this.diff( - selectedNodes, - nodes.list.entries - ); - - if (!remainingNodes.length) { - this.restoreNotification(status); - this.refresh(); - } else { - this.restore(remainingNodes); - } - }); - } - - private restoreNode(node: MinimalNodeEntity): Observable { - const { entry } = node; - - return this.contentApi.restoreNode(entry.id) - .map(() => ({ - status: 1, - entry - })) - .catch(error => { - const { statusCode } = JSON.parse(error.message).error; - - return Observable.of({ - status: 0, - statusCode, - entry - }); - }); - } - - 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: DeletedNodeInfo[] = []): DeleteStatus { - 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(status: DeleteStatus): SnackbarAction { - if (status.someFailed && !status.oneFailed) { - return new SnackbarErrorAction( - '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 new SnackbarErrorAction( - 'APP.MESSAGES.ERRORS.TRASH.NODES_RESTORE.NODE_EXISTS', - { name: status.fail[0].entry.name } - ); - } else { - return new SnackbarErrorAction( - 'APP.MESSAGES.ERRORS.TRASH.NODES_RESTORE.GENERIC', - { name: status.fail[0].entry.name } - ); - } - } - - if (status.oneFailed && !status.fail[0].statusCode) { - return new SnackbarErrorAction( - 'APP.MESSAGES.ERRORS.TRASH.NODES_RESTORE.LOCATION_MISSING', - { name: status.fail[0].entry.name } - ); - } - - if (status.allSucceeded && !status.oneSucceeded) { - return new SnackbarInfoAction( - 'APP.MESSAGES.INFO.TRASH.NODES_RESTORE.PLURAL' - ); - } - - if (status.allSucceeded && status.oneSucceeded) { - return new SnackbarInfoAction( - 'APP.MESSAGES.INFO.TRASH.NODES_RESTORE.SINGULAR', - { name: status.success[0].entry.name } - ); - } - - return null; - } - - restoreNotification(status: DeleteStatus): void { - const message = this.getRestoreMessage(status); - - if (message) { - if (status.oneSucceeded && !status.someFailed) { - const isSite = this.isSite(status.success[0].entry); - const path: PathInfoEntity = status.success[0].entry.path; - const parent = path.elements[path.elements.length - 1]; - const route = isSite ? ['/libraries'] : ['/personal-files', parent.id]; - - const navigate = new NavigateRouteAction(route); - - message.userAction = new SnackbarUserAction( - 'APP.ACTIONS.VIEW', - navigate - ); - } - - this.store.dispatch(message); - } - } - - private isSite(entry: MinimalNodeEntryEntity): boolean { - return entry.nodeType === 'st:site'; - } - - private refresh(): void { - this.contentManagementService.nodesRestored.next(); + this.store.dispatch(new RestoreDeletedNodesAction(this.selection)); } } diff --git a/src/app/extensions/core.extensions.module.ts b/src/app/extensions/core.extensions.module.ts index 1cfbf3371..1b3cc1872 100644 --- a/src/app/extensions/core.extensions.module.ts +++ b/src/app/extensions/core.extensions.module.ts @@ -30,36 +30,30 @@ import { LayoutComponent } from '../components/layout/layout.component'; import { TrashcanComponent } from '../components/trashcan/trashcan.component'; import { ToolbarActionComponent } from './components/toolbar-action/toolbar-action.component'; import * as app from './evaluators/app.evaluators'; -import * as core from './evaluators/core.evaluators'; import { ExtensionService } from './extension.service'; export function setupExtensions(extensions: ExtensionService): Function { return () => new Promise(resolve => { - extensions - .setComponent('app.layout.main', LayoutComponent) - .setComponent('app.components.trashcan', TrashcanComponent) - .setAuthGuard('app.auth', AuthGuardEcm) + extensions.setComponents({ + 'app.layout.main': LayoutComponent, + 'app.components.trashcan': TrashcanComponent + }); - .setEvaluator('core.every', core.every) - .setEvaluator('core.some', core.some) - .setEvaluator('core.not', core.not) - .setEvaluator( - 'app.selection.canDownload', - app.canDownloadSelection - ) - .setEvaluator('app.selection.file', app.hasFileSelected) - .setEvaluator('app.selection.folder', app.hasFolderSelected) - .setEvaluator( - 'app.selection.folder.canUpdate', - app.canUpdateSelectedFolder - ) - .setEvaluator( - 'app.navigation.folder.canCreate', - app.canCreateFolder - ) - .setEvaluator('app.navigation.isTrashcan', app.isTrashcan) - .setEvaluator('app.navigation.isNotTrashcan', app.isNotTrashcan); + extensions.setAuthGuards({ + 'app.auth': AuthGuardEcm + }); + + extensions.setEvaluators({ + 'app.selection.canDownload': app.canDownloadSelection, + 'app.selection.notEmpty': app.hasSelection, + 'app.selection.file': app.hasFileSelected, + 'app.selection.folder': app.hasFolderSelected, + 'app.selection.folder.canUpdate': app.canUpdateSelectedFolder, + 'app.navigation.folder.canCreate': app.canCreateFolder, + 'app.navigation.isTrashcan': app.isTrashcan, + 'app.navigation.isNotTrashcan': app.isNotTrashcan + }); resolve(true); }); diff --git a/src/app/extensions/evaluators/app.evaluators.ts b/src/app/extensions/evaluators/app.evaluators.ts index 131e1edf6..79b5959ca 100644 --- a/src/app/extensions/evaluators/app.evaluators.ts +++ b/src/app/extensions/evaluators/app.evaluators.ts @@ -35,6 +35,11 @@ export function isNotTrashcan(context: RuleContext, ...args: RuleParameter[]): b return !isTrashcan(context, ...args); } +export function hasSelection(context: RuleContext, ...args: RuleParameter[]): boolean { + const { selection } = context; + return selection && !selection.isEmpty; +} + export function canCreateFolder(context: RuleContext, ...args: RuleParameter[]): boolean { const folder = context.navigation.currentFolder; if (folder) { diff --git a/src/app/extensions/extension.service.ts b/src/app/extensions/extension.service.ts index 3c6292895..06b9a6d70 100644 --- a/src/app/extensions/extension.service.ts +++ b/src/app/extensions/extension.service.ts @@ -35,6 +35,7 @@ import { NavBarGroupRef } from './navbar.extensions'; import { RouteRef } from './routing.extensions'; import { RuleContext, RuleRef, RuleEvaluator } from './rule.extensions'; import { ActionRef, ContentActionRef, ContentActionType } from './action.extensions'; +import * as core from './evaluators/core.evaluators'; @Injectable() export class ExtensionService implements RuleContext { @@ -63,6 +64,13 @@ export class ExtensionService implements RuleContext { navigation: NavigationState; constructor(private http: HttpClient, private store: Store) { + + this.evaluators = { + 'core.every': core.every, + 'core.some': core.some, + 'core.not': core.not + }; + this.store.select(selectionWithFolder).subscribe(result => { this.selection = result.selection; this.navigation = result.navigation; @@ -205,14 +213,22 @@ export class ExtensionService implements RuleContext { return []; } - setEvaluator(key: string, value: RuleEvaluator): ExtensionService { - this.evaluators[key] = value; - return this; + setEvaluators(values: { [key: string]: RuleEvaluator }) { + if (values) { + this.evaluators = Object.assign({}, this.evaluators, values); + } } - setAuthGuard(key: string, value: Type<{}>): ExtensionService { - this.authGuards[key] = value; - return this; + setAuthGuards(values: { [key: string]: Type<{}> }) { + if (values) { + this.authGuards = Object.assign({}, this.authGuards, values); + } + } + + setComponents(values: { [key: string]: Type<{}> }) { + if (values) { + this.components = Object.assign({}, this.components, values); + } } getRouteById(id: string): RouteRef { @@ -229,11 +245,6 @@ export class ExtensionService implements RuleContext { return this.navbar; } - setComponent(id: string, value: Type<{}>): ExtensionService { - this.components[id] = value; - return this; - } - getComponentById(id: string): Type<{}> { return this.components[id]; } @@ -382,6 +393,8 @@ export class ExtensionService implements RuleContext { const expression = this.runExpression(payload, context); this.store.dispatch({ type, payload: expression }); + } else { + this.store.dispatch({ type: id }); } } @@ -402,11 +415,17 @@ export class ExtensionService implements RuleContext { evaluateRule(ruleId: string): boolean { const ruleRef = this.rules.find(ref => ref.id === ruleId); + if (ruleRef) { const evaluator = this.evaluators[ruleRef.type]; if (evaluator) { return evaluator(this, ...ruleRef.parameters); } + } else { + const evaluator = this.evaluators[ruleId]; + if (evaluator) { + return evaluator(this); + } } return false; } diff --git a/src/app/services/content-management.service.ts b/src/app/services/content-management.service.ts index 8f1eadb16..fd5e38fed 100644 --- a/src/app/services/content-management.service.ts +++ b/src/app/services/content-management.service.ts @@ -23,21 +23,32 @@ * along with Alfresco. If not, see . */ -import { Subject } from 'rxjs/Rx'; +import { Subject, Observable } from 'rxjs/Rx'; import { Injectable } from '@angular/core'; import { MatDialog } from '@angular/material'; -import { FolderDialogComponent } from '@alfresco/adf-content-services'; +import { FolderDialogComponent, ConfirmDialogComponent } from '@alfresco/adf-content-services'; import { LibraryDialogComponent } from '../dialogs/library/library.dialog'; -import { SnackbarErrorAction } from '../store/actions'; +import { SnackbarErrorAction, SnackbarInfoAction, SnackbarAction, SnackbarWarningAction, + NavigateRouteAction, SnackbarUserAction } from '../store/actions'; import { Store } from '@ngrx/store'; import { AppStore } from '../store/states'; import { MinimalNodeEntity, MinimalNodeEntryEntity, Node, - SiteEntry + SiteEntry, + DeletedNodesPaging, + PathInfoEntity } from 'alfresco-js-api'; import { NodePermissionService } from './node-permission.service'; +import { NodeInfo, DeletedNodeInfo, DeleteStatus } from '../store/models'; +import { ContentApiService } from './content-api.service'; + +interface RestoredNode { + status: number; + entry: MinimalNodeEntryEntity; + statusCode?: number; +} @Injectable() export class ContentManagementService { @@ -53,6 +64,7 @@ export class ContentManagementService { constructor( private store: Store, + private contentApi: ContentApiService, private permission: NodePermissionService, private dialogRef: MatDialog ) {} @@ -144,4 +156,305 @@ export class ContentManagementService { target: 'allowableOperationsOnTarget' }); } + + purgeDeletedNodes(nodes: MinimalNodeEntity[]) { + if (!nodes || nodes.length === 0) { + return; + } + + const dialogRef = this.dialogRef.open(ConfirmDialogComponent, { + data: { + title: 'APP.DIALOGS.CONFIRM_PURGE.TITLE', + message: 'APP.DIALOGS.CONFIRM_PURGE.MESSAGE', + yesLabel: 'APP.DIALOGS.CONFIRM_PURGE.YES_LABEL', + noLabel: 'APP.DIALOGS.CONFIRM_PURGE.NO_LABEL' + }, + minWidth: '250px' + }); + + dialogRef.afterClosed().subscribe(result => { + if (result === true) { + const nodesToDelete: NodeInfo[] = nodes.map(node => { + const { name } = node.entry; + const id = node.entry.nodeId || node.entry.id; + + return { + id, + name + }; + }); + this.purgeNodes(nodesToDelete); + } + }); + } + + restoreDeletedNodes(selection: MinimalNodeEntity[] = []) { + if (!selection.length) { + return; + } + + const nodesWithPath = selection.filter(node => node.entry.path); + + if (selection.length && !nodesWithPath.length) { + const failedStatus = this.processStatus([]); + failedStatus.fail.push(...selection); + this.showRestoreNotification(failedStatus); + this.nodesRestored.next(); + return; + } + + let status: DeleteStatus; + + Observable.forkJoin(nodesWithPath.map(node => this.restoreNode(node))) + .do(restoredNodes => { + status = this.processStatus(restoredNodes); + }) + .flatMap(() => this.contentApi.getDeletedNodes()) + .subscribe((nodes: DeletedNodesPaging) => { + const selectedNodes = this.diff(status.fail, selection, false); + const remainingNodes = this.diff( + selectedNodes, + nodes.list.entries + ); + + if (!remainingNodes.length) { + this.showRestoreNotification(status); + this.nodesRestored.next(); + } else { + this.restoreDeletedNodes(remainingNodes); + } + }); + } + + private restoreNode(node: MinimalNodeEntity): Observable { + const { entry } = node; + + return this.contentApi.restoreNode(entry.id) + .map(() => ({ + status: 1, + entry + })) + .catch(error => { + const { statusCode } = JSON.parse(error.message).error; + + return Observable.of({ + status: 0, + statusCode, + entry + }); + }); + } + + private purgeNodes(selection: NodeInfo[] = []) { + if (!selection.length) { + return; + } + + const batch = selection.map(node => this.purgeDeletedNode(node)); + + Observable.forkJoin(batch).subscribe(purgedNodes => { + const status = this.processStatus(purgedNodes); + + if (status.success.length) { + this.nodesPurged.next(); + } + const message = this.getPurgeMessage(status); + if (message) { + this.store.dispatch(message); + } + }); + } + + private purgeDeletedNode(node: NodeInfo): Observable { + const { id, name } = node; + + return this.contentApi + .purgeDeletedNode(id) + .map(() => ({ + status: 1, + id, + name + })) + .catch(error => { + return Observable.of({ + status: 0, + id, + name + }); + }); + } + + private processStatus(data: Array<{ status: number }> = []): DeleteStatus { + 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 getPurgeMessage(status: DeleteStatus): SnackbarAction { + if (status.oneSucceeded && status.someFailed && !status.oneFailed) { + return new SnackbarWarningAction( + '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 new SnackbarWarningAction( + 'APP.MESSAGES.INFO.TRASH.NODES_PURGE.PARTIAL_PLURAL', + { + number: status.success.length, + failed: status.fail.length + } + ); + } + + if (status.oneSucceeded) { + return new SnackbarInfoAction( + 'APP.MESSAGES.INFO.TRASH.NODES_PURGE.SINGULAR', + { name: status.success[0].name } + ); + } + + if (status.oneFailed) { + return new SnackbarErrorAction( + 'APP.MESSAGES.ERRORS.TRASH.NODES_PURGE.SINGULAR', + { name: status.fail[0].name } + ); + } + + if (status.allSucceeded) { + return new SnackbarInfoAction( + 'APP.MESSAGES.INFO.TRASH.NODES_PURGE.PLURAL', + { number: status.success.length } + ); + } + + if (status.allFailed) { + return new SnackbarErrorAction( + 'APP.MESSAGES.ERRORS.TRASH.NODES_PURGE.PLURAL', + { number: status.fail.length } + ); + } + + return null; + } + + private showRestoreNotification(status: DeleteStatus): void { + const message = this.getRestoreMessage(status); + + if (message) { + if (status.oneSucceeded && !status.someFailed) { + const isSite = this.isSite(status.success[0].entry); + const path: PathInfoEntity = status.success[0].entry.path; + const parent = path.elements[path.elements.length - 1]; + const route = isSite ? ['/libraries'] : ['/personal-files', parent.id]; + + const navigate = new NavigateRouteAction(route); + + message.userAction = new SnackbarUserAction( + 'APP.ACTIONS.VIEW', + navigate + ); + } + + this.store.dispatch(message); + } + } + + private isSite(entry: MinimalNodeEntryEntity): boolean { + return entry.nodeType === 'st:site'; + } + + private getRestoreMessage(status: DeleteStatus): SnackbarAction { + if (status.someFailed && !status.oneFailed) { + return new SnackbarErrorAction( + '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 new SnackbarErrorAction( + 'APP.MESSAGES.ERRORS.TRASH.NODES_RESTORE.NODE_EXISTS', + { name: status.fail[0].entry.name } + ); + } else { + return new SnackbarErrorAction( + 'APP.MESSAGES.ERRORS.TRASH.NODES_RESTORE.GENERIC', + { name: status.fail[0].entry.name } + ); + } + } + + if (status.oneFailed && !status.fail[0].statusCode) { + return new SnackbarErrorAction( + 'APP.MESSAGES.ERRORS.TRASH.NODES_RESTORE.LOCATION_MISSING', + { name: status.fail[0].entry.name } + ); + } + + if (status.allSucceeded && !status.oneSucceeded) { + return new SnackbarInfoAction( + 'APP.MESSAGES.INFO.TRASH.NODES_RESTORE.PLURAL' + ); + } + + if (status.allSucceeded && status.oneSucceeded) { + return new SnackbarInfoAction( + 'APP.MESSAGES.INFO.TRASH.NODES_RESTORE.SINGULAR', + { name: status.success[0].entry.name } + ); + } + + return null; + } + + 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; + } + }); + } } diff --git a/src/app/store/actions/node.actions.ts b/src/app/store/actions/node.actions.ts index 958a5fd44..f3e06ee11 100644 --- a/src/app/store/actions/node.actions.ts +++ b/src/app/store/actions/node.actions.ts @@ -53,12 +53,12 @@ export class UndoDeleteNodesAction implements Action { export class RestoreDeletedNodesAction implements Action { readonly type = RESTORE_DELETED_NODES; - constructor(public payload: any[] = []) {} + constructor(public payload: Array) {} } export class PurgeDeletedNodesAction implements Action { readonly type = PURGE_DELETED_NODES; - constructor(public payload: NodeInfo[] = []) {} + constructor(public payload: Array) {} } export class DownloadNodesAction implements Action { diff --git a/src/app/store/effects/node.effects.ts b/src/app/store/effects/node.effects.ts index 48cc2bcb7..95794d3c2 100644 --- a/src/app/store/effects/node.effects.ts +++ b/src/app/store/effects/node.effects.ts @@ -48,7 +48,7 @@ import { Observable } from 'rxjs/Rx'; import { NodeInfo, DeleteStatus, DeletedNodeInfo } from '../models'; import { ContentApiService } from '../../services/content-api.service'; import { currentFolder, appSelection } from '../selectors/app.selectors'; -import { EditFolderAction, EDIT_FOLDER } from '../actions/node.actions'; +import { EditFolderAction, EDIT_FOLDER, RestoreDeletedNodesAction, RESTORE_DELETED_NODES } from '../actions/node.actions'; @Injectable() export class NodeEffects { @@ -63,7 +63,37 @@ export class NodeEffects { purgeDeletedNodes$ = this.actions$.pipe( ofType(PURGE_DELETED_NODES), map(action => { - this.purgeNodes(action.payload); + if (action && action.payload && action.payload.length > 0) { + this.contentManagementService.purgeDeletedNodes(action.payload); + } else { + this.store + .select(appSelection) + .take(1) + .subscribe(selection => { + if (selection && selection.count > 0) { + this.contentManagementService.purgeDeletedNodes(selection.nodes); + } + }); + } + }) + ); + + @Effect({ dispatch: false }) + restoreDeletedNodes$ = this.actions$.pipe( + ofType(RESTORE_DELETED_NODES), + map(action => { + if (action && action.payload && action.payload.length > 0) { + this.contentManagementService.restoreDeletedNodes(action.payload); + } else { + this.store + .select(appSelection) + .take(1) + .subscribe(selection => { + if (selection && selection.count > 0) { + this.contentManagementService.restoreDeletedNodes(selection.nodes); + } + }); + } }) ); @@ -284,45 +314,6 @@ export class NodeEffects { return null; } - private purgeNodes(selection: NodeInfo[] = []) { - if (!selection.length) { - return; - } - - const batch = selection.map(node => this.purgeDeletedNode(node)); - - Observable.forkJoin(batch).subscribe(purgedNodes => { - const status = this.processStatus(purgedNodes); - - if (status.success.length) { - this.contentManagementService.nodesPurged.next(); - } - const message = this.getPurgeMessage(status); - if (message) { - this.store.dispatch(message); - } - }); - } - - private purgeDeletedNode(node: NodeInfo): Observable { - const { id, name } = node; - - return this.contentApi - .purgeDeletedNode(id) - .map(() => ({ - status: 1, - id, - name - })) - .catch(error => { - return Observable.of({ - status: 0, - id, - name - }); - }); - } - private processStatus(data: DeletedNodeInfo[] = []): DeleteStatus { const status = { fail: [], @@ -361,56 +352,4 @@ export class NodeEffects { return acc; }, status); } - - private getPurgeMessage(status: DeleteStatus): SnackbarAction { - if (status.oneSucceeded && status.someFailed && !status.oneFailed) { - return new SnackbarWarningAction( - '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 new SnackbarWarningAction( - 'APP.MESSAGES.INFO.TRASH.NODES_PURGE.PARTIAL_PLURAL', - { - number: status.success.length, - failed: status.fail.length - } - ); - } - - if (status.oneSucceeded) { - return new SnackbarInfoAction( - 'APP.MESSAGES.INFO.TRASH.NODES_PURGE.SINGULAR', - { name: status.success[0].name } - ); - } - - if (status.oneFailed) { - return new SnackbarErrorAction( - 'APP.MESSAGES.ERRORS.TRASH.NODES_PURGE.SINGULAR', - { name: status.fail[0].name } - ); - } - - if (status.allSucceeded) { - return new SnackbarInfoAction( - 'APP.MESSAGES.INFO.TRASH.NODES_PURGE.PLURAL', - { number: status.success.length } - ); - } - - if (status.allFailed) { - return new SnackbarErrorAction( - 'APP.MESSAGES.ERRORS.TRASH.NODES_PURGE.PLURAL', - { number: status.fail.length } - ); - } - - return null; - } } diff --git a/src/assets/app.extensions.json b/src/assets/app.extensions.json index 52769e6c7..907873efd 100644 --- a/src/assets/app.extensions.json +++ b/src/assets/app.extensions.json @@ -9,8 +9,12 @@ "rules": [ { - "id": "app.create.canCreateFolder", - "type": "app.navigation.folder.canCreate" + "id": "app.trashcan.hasSelection", + "type": "core.every", + "parameters": [ + { "type": "rule", "value": "app.selection.notEmpty" }, + { "type": "rule", "value": "app.navigation.isTrashcan" } + ] }, { "id": "app.toolbar.canEditFolder", @@ -47,25 +51,6 @@ } ], - "actions": [ - { - "id": "app.actions.createFolder", - "type": "CREATE_FOLDER" - }, - { - "id": "app.actions.editFolder", - "type": "EDIT_FOLDER" - }, - { - "id": "app.actions.download", - "type": "DOWNLOAD_NODES" - }, - { - "id": "app.actions.preview", - "type": "VIEW_FILE" - } - ], - "features": { "create": [ { @@ -74,10 +59,10 @@ "icon": "create_new_folder", "title": "ext: Create Folder", "actions": { - "click": "app.actions.createFolder" + "click": "CREATE_FOLDER" }, "rules": { - "enabled": "app.create.canCreateFolder" + "enabled": "app.navigation.folder.canCreate" } } ], @@ -149,10 +134,10 @@ "title": "APP.NEW_MENU.TOOLTIPS.CREATE_FOLDER", "icon": "create_new_folder", "actions": { - "click": "app.actions.createFolder" + "click": "CREATE_FOLDER" }, "rules": { - "visible": "app.create.canCreateFolder" + "visible": "app.navigation.folder.canCreate" } }, { @@ -162,7 +147,7 @@ "title": "APP.ACTIONS.VIEW", "icon": "open_in_browser", "actions": { - "click": "app.actions.preview" + "click": "VIEW_FILE" }, "rules": { "visible": "app.toolbar.canViewFile" @@ -175,7 +160,7 @@ "title": "APP.ACTIONS.DOWNLOAD", "icon": "get_app", "actions": { - "click": "app.actions.download" + "click": "DOWNLOAD_NODES" }, "rules": { "visible": "app.toolbar.canDownload" @@ -188,12 +173,36 @@ "title": "APP.ACTIONS.EDIT", "icon": "create", "actions": { - "click": "app.actions.editFolder" + "click": "EDIT_FOLDER" }, "rules": { "visible": "app.toolbar.canEditFolder" } }, + { + "id": "app.toolbar.purgeDeletedNodes", + "type": "button", + "title": "APP.ACTIONS.DELETE_PERMANENT", + "icon": "delete_forever", + "actions": { + "click": "PURGE_DELETED_NODES" + }, + "rules": { + "visible": "app.trashcan.hasSelection" + } + }, + { + "id": "app.toolbar.restoreDeletedNodes", + "type": "button", + "title": "APP.ACTIONS.RESTORE", + "icon": "restore", + "actions": { + "click": "RESTORE_DELETED_NODES" + }, + "rules": { + "visible": "app.trashcan.hasSelection" + } + }, { "id": "app.toolbar.separator.2", "order": 200,