[ACA-2869] Create File from template - keep the dialog open if creation fails due to the usage of a duplicate file name (#1299)

* rename create from template flow action

* create from template action

* dialog service

* subscribe to dialog service

* dispatch create file from template action on submit

* update tests

* subject value type

* break effects and refactoring

* update tests

* update docs

* change version number
This commit is contained in:
Cilibiu Bogdan 2020-01-16 11:21:06 +02:00 committed by Adina Parpalita
parent d12079e2a7
commit 0bc4a3453b
8 changed files with 229 additions and 78 deletions

View File

@ -124,4 +124,5 @@ Below is the list of public actions types you can use in the plugin definitions
| 1.8.0 | VIEW_NODE | NodeId<`string`> , [ViewNodeExtras](../features/file-viewer.md#details)<`any`> | Lightweight preview of a node by id. Can be invoked from extensions. For details also see [File Viewer](../features/file-viewer.md#details) | | 1.8.0 | VIEW_NODE | NodeId<`string`> , [ViewNodeExtras](../features/file-viewer.md#details)<`any`> | Lightweight preview of a node by id. Can be invoked from extensions. For details also see [File Viewer](../features/file-viewer.md#details) |
| 1.8.0 | CLOSE_PREVIEW | n/a | Closes the viewer ( preview of the item ) | | 1.8.0 | CLOSE_PREVIEW | n/a | Closes the viewer ( preview of the item ) |
| 1.9.0 | RESET_SELECTION | n/a | Resets active document list selection | | 1.9.0 | RESET_SELECTION | n/a | Resets active document list selection |
| 2.0.0 | CREATE_FILE_FROM_TEMPLATE | n/a | Invoke a dialog listing `Node Templates` folder. Selected template can be copied in the current folder from where tha action was invoked | | 1.10.0 | FILE_FROM_TEMPLATE | n/a | Invoke dialogs flow for creating a file from selected template|
| 1.10.0 | CREATE_FILE_FROM_TEMPLATE | Node | Copy selected tetmplate into current folder |

View File

@ -24,13 +24,21 @@
*/ */
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import { Node } from '@alfresco/js-api';
export enum TemplateActionTypes { export enum TemplateActionTypes {
FileFromTemplate = 'FILE_FROM_TEMPLATE',
CreateFileFromTemplate = 'CREATE_FILE_FROM_TEMPLATE' CreateFileFromTemplate = 'CREATE_FILE_FROM_TEMPLATE'
} }
export class FileFromTemplate implements Action {
readonly type = TemplateActionTypes.FileFromTemplate;
constructor() {}
}
export class CreateFileFromTemplate implements Action { export class CreateFileFromTemplate implements Action {
readonly type = TemplateActionTypes.CreateFileFromTemplate; readonly type = TemplateActionTypes.CreateFileFromTemplate;
constructor() {} constructor(public payload: Node) {}
} }

View File

@ -0,0 +1,35 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2019 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
import { Node } from '@alfresco/js-api';
@Injectable({
providedIn: 'root'
})
export class CreateFromTemplateDialogService {
success$: Subject<Node> = new Subject();
}

View File

@ -32,6 +32,10 @@ import {
MatDialogRef, MatDialogRef,
MAT_DIALOG_DATA MAT_DIALOG_DATA
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { CreateFileFromTemplate } from '@alfresco/aca-shared/store';
import { Node } from '@alfresco/js-api';
import { CreateFromTemplateDialogService } from './create-from-template-dialog.service';
function text(length: number) { function text(length: number) {
return new Array(length) return new Array(length)
@ -47,6 +51,8 @@ describe('CreateFileFromTemplateDialogComponent', () => {
let fixture: ComponentFixture<CreateFileFromTemplateDialogComponent>; let fixture: ComponentFixture<CreateFileFromTemplateDialogComponent>;
let component: CreateFileFromTemplateDialogComponent; let component: CreateFileFromTemplateDialogComponent;
let dialogRef: MatDialogRef<CreateFileFromTemplateDialogComponent>; let dialogRef: MatDialogRef<CreateFileFromTemplateDialogComponent>;
let store;
let createFromTemplateDialogService: CreateFromTemplateDialogService;
const data = { const data = {
id: 'node-id', id: 'node-id',
@ -62,6 +68,12 @@ describe('CreateFileFromTemplateDialogComponent', () => {
imports: [CoreModule.forRoot(), AppTestingModule, MatDialogModule], imports: [CoreModule.forRoot(), AppTestingModule, MatDialogModule],
declarations: [CreateFileFromTemplateDialogComponent], declarations: [CreateFileFromTemplateDialogComponent],
providers: [ providers: [
{
provide: Store,
useValue: {
dispatch: jasmine.createSpy('dispatch')
}
},
{ provide: MAT_DIALOG_DATA, useValue: data }, { provide: MAT_DIALOG_DATA, useValue: data },
{ {
provide: MatDialogRef, provide: MatDialogRef,
@ -74,6 +86,10 @@ describe('CreateFileFromTemplateDialogComponent', () => {
fixture = TestBed.createComponent(CreateFileFromTemplateDialogComponent); fixture = TestBed.createComponent(CreateFileFromTemplateDialogComponent);
dialogRef = TestBed.get(MatDialogRef); dialogRef = TestBed.get(MatDialogRef);
store = TestBed.get(Store);
createFromTemplateDialogService = TestBed.get(
CreateFromTemplateDialogService
);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
@ -119,7 +135,15 @@ describe('CreateFileFromTemplateDialogComponent', () => {
expect(component.form.invalid).toBe(true); expect(component.form.invalid).toBe(true);
}); });
it('should update data with form values', () => { it('should create node from template with form values', () => {
const newNode = {
id: 'node-id',
name: 'new-node-name',
properties: {
'cm:title': 'new-node-title',
'cm:description': 'new-node-description'
}
} as Node;
component.form.controls.name.setValue('new-node-name'); component.form.controls.name.setValue('new-node-name');
component.form.controls.title.setValue('new-node-title'); component.form.controls.title.setValue('new-node-title');
component.form.controls.description.setValue('new-node-description'); component.form.controls.description.setValue('new-node-description');
@ -128,13 +152,27 @@ describe('CreateFileFromTemplateDialogComponent', () => {
component.onSubmit(); component.onSubmit();
expect(dialogRef.close['calls'].argsFor(0)[0]).toEqual({ expect(store.dispatch).toHaveBeenCalledWith(
new CreateFileFromTemplate(newNode)
);
});
it('should close dialog on create file from template success', done => {
const newNode = {
id: 'node-id', id: 'node-id',
name: 'new-node-name', name: 'new-node-name',
properties: { properties: {
'cm:title': 'new-node-title', 'cm:title': 'new-node-title',
'cm:description': 'new-node-description' 'cm:description': 'new-node-description'
} }
}); } as Node;
fixture.detectChanges();
createFromTemplateDialogService.success$.subscribe(node => {
expect(dialogRef.close).toHaveBeenCalledWith(node);
done();
});
createFromTemplateDialogService.success$.next(newNode);
}); });
}); });

View File

@ -33,6 +33,9 @@ import {
FormControl, FormControl,
ValidationErrors ValidationErrors
} from '@angular/forms'; } from '@angular/forms';
import { CreateFromTemplateDialogService } from './create-from-template-dialog.service';
import { Store } from '@ngrx/store';
import { AppStore, CreateFileFromTemplate } from '@alfresco/aca-shared/store';
@Component({ @Component({
templateUrl: './create-from-template.dialog.html', templateUrl: './create-from-template.dialog.html',
@ -43,12 +46,18 @@ export class CreateFileFromTemplateDialogComponent implements OnInit {
public form: FormGroup; public form: FormGroup;
constructor( constructor(
private createFromTemplateDialogService: CreateFromTemplateDialogService,
private store: Store<AppStore>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private dialogRef: MatDialogRef<CreateFileFromTemplateDialogComponent>, private dialogRef: MatDialogRef<CreateFileFromTemplateDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any @Inject(MAT_DIALOG_DATA) public data: any
) {} ) {}
ngOnInit() { ngOnInit() {
this.createFromTemplateDialogService.success$.subscribe((data: Node) => {
this.dialogRef.close(data);
});
this.form = this.formBuilder.group({ this.form = this.formBuilder.group({
name: [ name: [
this.data.name, this.data.name,
@ -76,7 +85,7 @@ export class CreateFileFromTemplateDialogComponent implements OnInit {
} }
}; };
const data: Node = Object.assign({}, this.data, update); const data: Node = Object.assign({}, this.data, update);
this.dialogRef.close(data); this.store.dispatch(new CreateFileFromTemplate(data));
} }
close() { close() {

View File

@ -30,19 +30,24 @@ import { EffectsModule } from '@ngrx/effects';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { import {
CreateFileFromTemplate, CreateFileFromTemplate,
FileFromTemplate,
SnackbarErrorAction SnackbarErrorAction
} from '@alfresco/aca-shared/store'; } from '@alfresco/aca-shared/store';
import { CreateFileFromTemplateService } from '../../services/create-file-from-template.service'; import { CreateFileFromTemplateService } from '../../services/create-file-from-template.service';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { AlfrescoApiServiceMock, AlfrescoApiService } from '@alfresco/adf-core'; import { AlfrescoApiServiceMock, AlfrescoApiService } from '@alfresco/adf-core';
import { ContentManagementService } from '../../services/content-management.service'; import { ContentManagementService } from '../../services/content-management.service';
import { Node } from '@alfresco/js-api'; import { Node, NodeEntry } from '@alfresco/js-api';
import { CreateFromTemplateDialogService } from '../../dialogs/node-templates/create-from-template-dialog.service';
describe('TemplateEffects', () => { describe('TemplateEffects', () => {
let store: Store<any>; let store: Store<any>;
let createFileFromTemplateService: CreateFileFromTemplateService; let createFileFromTemplateService: CreateFileFromTemplateService;
let alfrescoApiService: AlfrescoApiService; let alfrescoApiService: AlfrescoApiService;
let contentManagementService: ContentManagementService; let contentManagementService: ContentManagementService;
let createFromTemplateDialogService: CreateFromTemplateDialogService;
let copyNodeSpy;
let updateNodeSpy;
const node: Node = { const node: Node = {
name: 'node-name', name: 'node-name',
id: 'node-id', id: 'node-id',
@ -71,91 +76,125 @@ describe('TemplateEffects', () => {
store = TestBed.get(Store); store = TestBed.get(Store);
createFileFromTemplateService = TestBed.get(CreateFileFromTemplateService); createFileFromTemplateService = TestBed.get(CreateFileFromTemplateService);
alfrescoApiService = TestBed.get(AlfrescoApiService); alfrescoApiService = TestBed.get(AlfrescoApiService);
createFromTemplateDialogService = TestBed.get(
CreateFromTemplateDialogService
);
contentManagementService = TestBed.get(ContentManagementService); contentManagementService = TestBed.get(ContentManagementService);
spyOn(store, 'dispatch').and.callThrough();
spyOn(createFromTemplateDialogService.success$, 'next');
spyOn(contentManagementService.reload, 'next'); spyOn(contentManagementService.reload, 'next');
spyOn(store, 'select').and.returnValue(of({ id: 'parent-id' })); spyOn(store, 'select').and.returnValue(of({ id: 'parent-id' }));
spyOn(createFileFromTemplateService, 'openTemplatesDialog').and.returnValue( spyOn(createFileFromTemplateService, 'openTemplatesDialog').and.returnValue(
of([{ id: 'template-id' }]) of([{ id: 'template-id' }])
); );
copyNodeSpy = spyOn(alfrescoApiService.getInstance().nodes, 'copyNode');
updateNodeSpy = spyOn(alfrescoApiService.getInstance().nodes, 'updateNode');
});
afterEach(() => {
copyNodeSpy.calls.reset();
updateNodeSpy.calls.reset();
}); });
it('should reload content on create file from template', fakeAsync(() => { it('should reload content on create file from template', fakeAsync(() => {
spyOn(alfrescoApiService.getInstance().nodes, 'copyNode').and.returnValue(
of({ entry: { id: 'node-id' } })
);
spyOn(alfrescoApiService.getInstance().nodes, 'updateNode').and.returnValue(
of({})
);
spyOn( spyOn(
createFileFromTemplateService, createFileFromTemplateService,
'createTemplateDialog' 'createTemplateDialog'
).and.returnValue({ afterClosed: () => of(node) }); ).and.returnValue({ afterClosed: () => of(node) });
store.dispatch(new CreateFileFromTemplate()); store.dispatch(new FileFromTemplate());
tick(300); tick(300);
expect(contentManagementService.reload.next).toHaveBeenCalled(); expect(contentManagementService.reload.next).toHaveBeenCalled();
})); }));
it('should raise error when copyNode api fails', fakeAsync(() => { it('should not reload content if no file was created', fakeAsync(() => {
spyOn(store, 'dispatch').and.callThrough(); spyOn(
spyOn(alfrescoApiService.getInstance().nodes, 'copyNode').and.returnValue( createFileFromTemplateService,
'createTemplateDialog'
).and.returnValue({ afterClosed: () => of(null) });
store.dispatch(new FileFromTemplate());
tick(300);
expect(contentManagementService.reload.next).not.toHaveBeenCalled();
}));
it('should call dialog service success event on create file from template', fakeAsync(() => {
copyNodeSpy.and.returnValue(
of({ entry: { id: 'node-id', properties: {} } })
);
updateNodeSpy.and.returnValue(of({ entry: node }));
store.dispatch(new CreateFileFromTemplate(node));
tick();
expect(createFromTemplateDialogService.success$.next).toHaveBeenCalledWith(
node
);
}));
it('should raise generic error when copyNode api fails', fakeAsync(() => {
copyNodeSpy.and.returnValue(
Promise.reject({ Promise.reject({
message: `{ "error": { "statusCode": 404 } } ` message: `{ "error": { "statusCode": 404 } } `
}) })
); );
spyOn( store.dispatch(new CreateFileFromTemplate(node));
createFileFromTemplateService, tick();
'createTemplateDialog'
).and.returnValue({ afterClosed: () => of(node) });
store.dispatch(new CreateFileFromTemplate()); expect(
tick(300); createFromTemplateDialogService.success$.next
).not.toHaveBeenCalledWith();
expect(contentManagementService.reload.next).not.toHaveBeenCalled();
expect(store.dispatch['calls'].argsFor(1)[0]).toEqual( expect(store.dispatch['calls'].argsFor(1)[0]).toEqual(
new SnackbarErrorAction('APP.MESSAGES.ERRORS.GENERIC') new SnackbarErrorAction('APP.MESSAGES.ERRORS.GENERIC')
); );
})); }));
it('should raise error when updateNode api fails', fakeAsync(() => { it('should raise name conflict error when copyNode api returns 409', fakeAsync(() => {
spyOn(store, 'dispatch').and.callThrough(); copyNodeSpy.and.returnValue(
spyOn(alfrescoApiService.getInstance().nodes, 'copyNode').and.returnValue( Promise.reject({
of({ entry: { id: 'node-id' } }) message: `{ "error": { "statusCode": 409 } } `
})
); );
spyOn(alfrescoApiService.getInstance().nodes, 'updateNode').and.returnValue( store.dispatch(new CreateFileFromTemplate(node));
tick();
expect(
createFromTemplateDialogService.success$.next
).not.toHaveBeenCalledWith();
expect(store.dispatch['calls'].argsFor(1)[0]).toEqual(
new SnackbarErrorAction('APP.MESSAGES.ERRORS.CONFLICT')
);
}));
it('should resolve error with current node value when updateNode api fails', fakeAsync(() => {
const test_node = {
entry: {
id: 'test-node-id',
properties: {
'cm:title': 'test-node-title',
'cm:description': 'test-node-description'
}
}
} as NodeEntry;
copyNodeSpy.and.returnValue(of(test_node));
updateNodeSpy.and.returnValue(
Promise.reject({ Promise.reject({
message: `{ "error": { "statusCode": 404 } } ` message: `{ "error": { "statusCode": 404 } } `
}) })
); );
spyOn( store.dispatch(new CreateFileFromTemplate(test_node.entry));
createFileFromTemplateService, tick();
'createTemplateDialog'
).and.returnValue({ afterClosed: () => of(node) });
store.dispatch(new CreateFileFromTemplate()); expect(createFromTemplateDialogService.success$.next).toHaveBeenCalledWith(
tick(300); test_node.entry
expect(contentManagementService.reload.next).not.toHaveBeenCalled();
expect(store.dispatch['calls'].argsFor(1)[0]).toEqual(
new SnackbarErrorAction('APP.MESSAGES.ERRORS.GENERIC')
); );
})); }));
it('should update file from template with form data', () => {
spyOn(alfrescoApiService.getInstance().nodes, 'copyNode').and.returnValue(
of({ entry: { id: 'node-id' } })
);
spyOn(
createFileFromTemplateService,
'createTemplateDialog'
).and.returnValue({ afterClosed: () => of(node) });
});
}); });

View File

@ -27,15 +27,15 @@ import { Effect, Actions, ofType } from '@ngrx/effects';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { import {
map, map,
withLatestFrom,
switchMap, switchMap,
catchError,
debounceTime, debounceTime,
flatMap, flatMap,
skipWhile take,
catchError
} from 'rxjs/operators'; } from 'rxjs/operators';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { import {
FileFromTemplate,
CreateFileFromTemplate, CreateFileFromTemplate,
TemplateActionTypes, TemplateActionTypes,
getCurrentFolder, getCurrentFolder,
@ -45,9 +45,9 @@ import {
import { CreateFileFromTemplateService } from '../../services/create-file-from-template.service'; import { CreateFileFromTemplateService } from '../../services/create-file-from-template.service';
import { AlfrescoApiService } from '@alfresco/adf-core'; import { AlfrescoApiService } from '@alfresco/adf-core';
import { ContentManagementService } from '../../services/content-management.service'; import { ContentManagementService } from '../../services/content-management.service';
import { from, of, Observable } from 'rxjs'; import { from, Observable, of } from 'rxjs';
import { NodeEntry, NodeBodyUpdate, MinimalNode } from '@alfresco/js-api'; import { NodeEntry, NodeBodyUpdate, Node } from '@alfresco/js-api';
import { CreateFromTemplateDialogService } from '../../dialogs/node-templates/create-from-template-dialog.service';
@Injectable() @Injectable()
export class TemplateEffects { export class TemplateEffects {
constructor( constructor(
@ -55,12 +55,13 @@ export class TemplateEffects {
private store: Store<AppStore>, private store: Store<AppStore>,
private apiService: AlfrescoApiService, private apiService: AlfrescoApiService,
private actions$: Actions, private actions$: Actions,
private createFromTemplateDialogService: CreateFromTemplateDialogService,
private createFileFromTemplateService: CreateFileFromTemplateService private createFileFromTemplateService: CreateFileFromTemplateService
) {} ) {}
@Effect({ dispatch: false }) @Effect({ dispatch: false })
fileFromTemplate$ = this.actions$.pipe( fileFromTemplate$ = this.actions$.pipe(
ofType<CreateFileFromTemplate>(TemplateActionTypes.CreateFileFromTemplate), ofType<FileFromTemplate>(TemplateActionTypes.FileFromTemplate),
map(() => { map(() => {
this.createFileFromTemplateService this.createFileFromTemplateService
.openTemplatesDialog() .openTemplatesDialog()
@ -70,15 +71,7 @@ export class TemplateEffects {
this.createFileFromTemplateService this.createFileFromTemplateService
.createTemplateDialog(node) .createTemplateDialog(node)
.afterClosed() .afterClosed()
), )
skipWhile(node => !node),
withLatestFrom(this.store.select(getCurrentFolder)),
switchMap(([template, parentNode]) => {
return this.copyNode(template, parentNode.id);
}),
catchError(error => {
return this.handleError(error);
})
) )
.subscribe((node: NodeEntry | null) => { .subscribe((node: NodeEntry | null) => {
if (node) { if (node) {
@ -88,10 +81,27 @@ export class TemplateEffects {
}) })
); );
private copyNode( @Effect({ dispatch: false })
source: MinimalNode, createFileFromTemplate$ = this.actions$.pipe(
parentId: string ofType<CreateFileFromTemplate>(TemplateActionTypes.CreateFileFromTemplate),
): Observable<NodeEntry> { map(action => {
this.store
.select(getCurrentFolder)
.pipe(
switchMap(folder => {
return this.copyNode(action.payload, folder.id);
}),
take(1)
)
.subscribe((node: NodeEntry | null) => {
if (node) {
this.createFromTemplateDialogService.success$.next(node.entry);
}
});
})
);
private copyNode(source: Node, parentId: string): Observable<NodeEntry> {
return from( return from(
this.apiService.getInstance().nodes.copyNode(source.id, { this.apiService.getInstance().nodes.copyNode(source.id, {
targetParentId: parentId, targetParentId: parentId,
@ -99,25 +109,36 @@ export class TemplateEffects {
}) })
).pipe( ).pipe(
switchMap(node => switchMap(node =>
this.updateNode(node.entry.id, { this.updateNode(node, {
properties: { properties: {
'cm:title': source.properties['cm:title'], 'cm:title': source.properties['cm:title'],
'cm:description': source.properties['cm:description'] 'cm:description': source.properties['cm:description']
} }
}) })
) ),
catchError(error => {
return this.handleError(error);
})
); );
} }
private updateNode( private updateNode(
id: string, node: NodeEntry,
update: NodeBodyUpdate update: NodeBodyUpdate
): Observable<NodeEntry> { ): Observable<NodeEntry> {
return from(this.apiService.getInstance().nodes.updateNode(id, update)); return from(
this.apiService.getInstance().nodes.updateNode(node.entry.id, update)
).pipe(catchError(() => of(node)));
} }
private handleError(error: Error): Observable<null> { private handleError(error: Error): Observable<null> {
const { statusCode } = JSON.parse(error.message).error; let statusCode: number;
try {
statusCode = JSON.parse(error.message).error.statusCode;
} catch (e) {
statusCode = null;
}
if (statusCode !== 409) { if (statusCode !== 409) {
this.store.dispatch( this.store.dispatch(

View File

@ -116,7 +116,7 @@
"description": "APP.NEW_MENU.MENU_ITEMS.FILE_TEMPLATE", "description": "APP.NEW_MENU.MENU_ITEMS.FILE_TEMPLATE",
"description-disabled": "APP.NEW_MENU.TOOLTIPS.CREATE_FILE_NOT_ALLOWED", "description-disabled": "APP.NEW_MENU.TOOLTIPS.CREATE_FILE_NOT_ALLOWED",
"actions": { "actions": {
"click": "CREATE_FILE_FROM_TEMPLATE" "click": "FILE_FROM_TEMPLATE"
}, },
"rules": { "rules": {
"enabled": "app.navigation.folder.canUpload" "enabled": "app.navigation.folder.canUpload"