[ADF-1733] Restore files and folders from Trash (#2467)

* restore nodes

* changed @Inputs implementation

* NotificationService over mdSnackBar
This commit is contained in:
Cilibiu Bogdan
2017-10-14 13:24:16 +03:00
committed by Denys Vuika
parent a102a7ffb2
commit bfe8fc8d15
8 changed files with 686 additions and 2 deletions

View File

@@ -6,8 +6,20 @@
</md-option> </md-option>
</md-select> </md-select>
</adf-toolbar-title> </adf-toolbar-title>
<div fxFlex="0 1 auto" class="adf-document-action-buttons" fxShow fxHide.lt-sm="true">
<button md-icon-button
(restore)="documentList.reload()"
[disabled]="!documentList.selection.length"
*ngIf="selectedSource === '-trashcan-'"
location="/files"
[adf-restore]="documentList.selection">
<md-icon>restore</md-icon>
</button>
</div>
</adf-toolbar> </adf-toolbar>
<adf-document-list <adf-document-list
[currentFolderId]="selectedSource" [currentFolderId]="selectedSource"
locationFormat="/files"> locationFormat="/files"
selectionMode="multiple">
</adf-document-list> </adf-document-list>

View File

@@ -15,7 +15,8 @@
* limitations under the License. * limitations under the License.
*/ */
import { Component, Input } from '@angular/core'; import { Component, Input, ViewChild } from '@angular/core';
import { DocumentListComponent } from 'ng2-alfresco-documentlist';
@Component({ @Component({
selector: 'adf-custom-sources-demo', selector: 'adf-custom-sources-demo',
@@ -26,6 +27,9 @@ export class CustomSourcesComponent {
@Input() @Input()
selectedSource = '-recent-'; selectedSource = '-recent-';
@ViewChild(DocumentListComponent)
documentList: DocumentListComponent;
sources = [ sources = [
{ title: 'Favorites', value: '-favorites-' }, { title: 'Favorites', value: '-favorites-' },
{ title: 'Recent', value: '-recent-' }, { title: 'Recent', value: '-recent-' },

View File

@@ -315,6 +315,7 @@ for more information about installing and using the source code.
- [Context menu directive](docs/context-menu.directive.md) - [Context menu directive](docs/context-menu.directive.md)
- [Logout directive](docs/logout.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 permission directive](docs/node-permission.directive.md)
- [Node favorite directive](docs/node-favorite.directive.md) - [Node favorite directive](docs/node-favorite.directive.md)
- [Upload directive](docs/upload.directive.md) - [Upload directive](docs/upload.directive.md)

View File

@@ -0,0 +1,52 @@
# Node Restore directive
<!-- markdown-toc start - Don't edit this section. npm run toc to generate it-->
<!-- toc -->
- [Basic Usage](#basic-usage)
* [Properties](#properties)
* [Events](#events)
- [Details](#details)
<!-- tocstop -->
<!-- markdown-toc end -->
## Basic Usage
```html
<adf-toolbar title="toolbar example">
<button md-icon-button
location="/files"
[adf-restore]="documentList.selection"
(restore)="documentList.reload()">
<md-icon>restore</md-icon>
</button>
</adf-toolbar>
<adf-document-list #documentList
currentFolderId="-trash-" ...>
...
</adf-document-list>
```
### 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

View File

@@ -122,6 +122,7 @@ import {
} from './src/components/info-drawer/info-drawer-layout.component'; } from './src/components/info-drawer/info-drawer-layout.component';
import { InfoDrawerComponent, InfoDrawerTabComponent } from './src/components/info-drawer/info-drawer.component'; import { InfoDrawerComponent, InfoDrawerTabComponent } from './src/components/info-drawer/info-drawer.component';
import { NodePermissionDirective } from './src/directives/node-permission.directive'; import { NodePermissionDirective } from './src/directives/node-permission.directive';
import { NodeRestoreDirective } from './src/directives/node-restore.directive';
import { UploadDirective } from './src/directives/upload.directive'; import { UploadDirective } from './src/directives/upload.directive';
import { FileSizePipe } from './src/pipes/file-size.pipe'; 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/components/info-drawer/info-drawer.component';
export * from './src/directives/upload.directive'; export * from './src/directives/upload.directive';
export * from './src/directives/highlight.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-permission.directive';
export * from './src/directives/node-favorite.directive'; export * from './src/directives/node-favorite.directive';
export * from './src/utils/index'; export * from './src/utils/index';
@@ -248,6 +250,7 @@ export function createTranslateLoader(http: Http, logService: LogService) {
...pipes(), ...pipes(),
LogoutDirective, LogoutDirective,
UploadDirective, UploadDirective,
NodeRestoreDirective,
NodePermissionDirective, NodePermissionDirective,
NodeFavoriteDirective, NodeFavoriteDirective,
HighlightDirective, HighlightDirective,
@@ -291,6 +294,7 @@ export function createTranslateLoader(http: Http, logService: LogService) {
...pipes(), ...pipes(),
LogoutDirective, LogoutDirective,
UploadDirective, UploadDirective,
NodeRestoreDirective,
NodePermissionDirective, NodePermissionDirective,
NodeFavoriteDirective, NodeFavoriteDirective,
HighlightDirective, HighlightDirective,

View File

@@ -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: `
<div [adf-restore]="selection"
(restore)="done()">
</div>`
})
class TestComponent {
selection = [];
done = jasmine.createSpy('done');
}
describe('NodeRestoreDirective', () => {
let fixture: ComponentFixture<TestComponent>;
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();
}));
});
});

View File

@@ -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<any> = 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<DeletedNodeEntry[]> {
return Observable.forkJoin(batch.map((node) => this.restoreNode(node)));
}
private getNodesWithPath(selection): DeletedNodeEntry[] {
return selection.filter((node) => node.entry.path);
}
private getDeletedNodes(): Observable<DeletedNodeEntry> {
const promise = this.alfrescoApiService.getInstance()
.core.nodesApi.getDeletedNodes({ include: [ 'path' ] });
return Observable.from(promise);
}
private restoreNode(node): Observable<any> {
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<string|any> {
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();
}
}

View File

@@ -13,6 +13,15 @@
}, },
"TITLE": "Adding files to zip, this could take a few minutes" "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"
} }
} }
} }