[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 | CLOSE_PREVIEW | n/a | Closes the viewer ( preview of the item ) |
| 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 { Node } from '@alfresco/js-api';
export enum TemplateActionTypes {
FileFromTemplate = 'FILE_FROM_TEMPLATE',
CreateFileFromTemplate = 'CREATE_FILE_FROM_TEMPLATE'
}
export class FileFromTemplate implements Action {
readonly type = TemplateActionTypes.FileFromTemplate;
constructor() {}
}
export class CreateFileFromTemplate implements Action {
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,
MAT_DIALOG_DATA
} 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) {
return new Array(length)
@ -47,6 +51,8 @@ describe('CreateFileFromTemplateDialogComponent', () => {
let fixture: ComponentFixture<CreateFileFromTemplateDialogComponent>;
let component: CreateFileFromTemplateDialogComponent;
let dialogRef: MatDialogRef<CreateFileFromTemplateDialogComponent>;
let store;
let createFromTemplateDialogService: CreateFromTemplateDialogService;
const data = {
id: 'node-id',
@ -62,6 +68,12 @@ describe('CreateFileFromTemplateDialogComponent', () => {
imports: [CoreModule.forRoot(), AppTestingModule, MatDialogModule],
declarations: [CreateFileFromTemplateDialogComponent],
providers: [
{
provide: Store,
useValue: {
dispatch: jasmine.createSpy('dispatch')
}
},
{ provide: MAT_DIALOG_DATA, useValue: data },
{
provide: MatDialogRef,
@ -74,6 +86,10 @@ describe('CreateFileFromTemplateDialogComponent', () => {
fixture = TestBed.createComponent(CreateFileFromTemplateDialogComponent);
dialogRef = TestBed.get(MatDialogRef);
store = TestBed.get(Store);
createFromTemplateDialogService = TestBed.get(
CreateFromTemplateDialogService
);
component = fixture.componentInstance;
fixture.detectChanges();
@ -119,7 +135,15 @@ describe('CreateFileFromTemplateDialogComponent', () => {
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.title.setValue('new-node-title');
component.form.controls.description.setValue('new-node-description');
@ -128,13 +152,27 @@ describe('CreateFileFromTemplateDialogComponent', () => {
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',
name: 'new-node-name',
properties: {
'cm:title': 'new-node-title',
'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,
ValidationErrors
} 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({
templateUrl: './create-from-template.dialog.html',
@ -43,12 +46,18 @@ export class CreateFileFromTemplateDialogComponent implements OnInit {
public form: FormGroup;
constructor(
private createFromTemplateDialogService: CreateFromTemplateDialogService,
private store: Store<AppStore>,
private formBuilder: FormBuilder,
private dialogRef: MatDialogRef<CreateFileFromTemplateDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any
) {}
ngOnInit() {
this.createFromTemplateDialogService.success$.subscribe((data: Node) => {
this.dialogRef.close(data);
});
this.form = this.formBuilder.group({
name: [
this.data.name,
@ -76,7 +85,7 @@ export class CreateFileFromTemplateDialogComponent implements OnInit {
}
};
const data: Node = Object.assign({}, this.data, update);
this.dialogRef.close(data);
this.store.dispatch(new CreateFileFromTemplate(data));
}
close() {

View File

@ -30,19 +30,24 @@ import { EffectsModule } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import {
CreateFileFromTemplate,
FileFromTemplate,
SnackbarErrorAction
} from '@alfresco/aca-shared/store';
import { CreateFileFromTemplateService } from '../../services/create-file-from-template.service';
import { of } from 'rxjs';
import { AlfrescoApiServiceMock, AlfrescoApiService } from '@alfresco/adf-core';
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', () => {
let store: Store<any>;
let createFileFromTemplateService: CreateFileFromTemplateService;
let alfrescoApiService: AlfrescoApiService;
let contentManagementService: ContentManagementService;
let createFromTemplateDialogService: CreateFromTemplateDialogService;
let copyNodeSpy;
let updateNodeSpy;
const node: Node = {
name: 'node-name',
id: 'node-id',
@ -71,91 +76,125 @@ describe('TemplateEffects', () => {
store = TestBed.get(Store);
createFileFromTemplateService = TestBed.get(CreateFileFromTemplateService);
alfrescoApiService = TestBed.get(AlfrescoApiService);
createFromTemplateDialogService = TestBed.get(
CreateFromTemplateDialogService
);
contentManagementService = TestBed.get(ContentManagementService);
spyOn(store, 'dispatch').and.callThrough();
spyOn(createFromTemplateDialogService.success$, 'next');
spyOn(contentManagementService.reload, 'next');
spyOn(store, 'select').and.returnValue(of({ id: 'parent-id' }));
spyOn(createFileFromTemplateService, 'openTemplatesDialog').and.returnValue(
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(() => {
spyOn(alfrescoApiService.getInstance().nodes, 'copyNode').and.returnValue(
of({ entry: { id: 'node-id' } })
);
spyOn(alfrescoApiService.getInstance().nodes, 'updateNode').and.returnValue(
of({})
);
spyOn(
createFileFromTemplateService,
'createTemplateDialog'
).and.returnValue({ afterClosed: () => of(node) });
store.dispatch(new CreateFileFromTemplate());
store.dispatch(new FileFromTemplate());
tick(300);
expect(contentManagementService.reload.next).toHaveBeenCalled();
}));
it('should raise error when copyNode api fails', fakeAsync(() => {
spyOn(store, 'dispatch').and.callThrough();
spyOn(alfrescoApiService.getInstance().nodes, 'copyNode').and.returnValue(
it('should not reload content if no file was created', fakeAsync(() => {
spyOn(
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({
message: `{ "error": { "statusCode": 404 } } `
})
);
spyOn(
createFileFromTemplateService,
'createTemplateDialog'
).and.returnValue({ afterClosed: () => of(node) });
store.dispatch(new CreateFileFromTemplate(node));
tick();
store.dispatch(new CreateFileFromTemplate());
tick(300);
expect(contentManagementService.reload.next).not.toHaveBeenCalled();
expect(
createFromTemplateDialogService.success$.next
).not.toHaveBeenCalledWith();
expect(store.dispatch['calls'].argsFor(1)[0]).toEqual(
new SnackbarErrorAction('APP.MESSAGES.ERRORS.GENERIC')
);
}));
it('should raise error when updateNode api fails', fakeAsync(() => {
spyOn(store, 'dispatch').and.callThrough();
spyOn(alfrescoApiService.getInstance().nodes, 'copyNode').and.returnValue(
of({ entry: { id: 'node-id' } })
it('should raise name conflict error when copyNode api returns 409', fakeAsync(() => {
copyNodeSpy.and.returnValue(
Promise.reject({
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({
message: `{ "error": { "statusCode": 404 } } `
})
);
spyOn(
createFileFromTemplateService,
'createTemplateDialog'
).and.returnValue({ afterClosed: () => of(node) });
store.dispatch(new CreateFileFromTemplate(test_node.entry));
tick();
store.dispatch(new CreateFileFromTemplate());
tick(300);
expect(contentManagementService.reload.next).not.toHaveBeenCalled();
expect(store.dispatch['calls'].argsFor(1)[0]).toEqual(
new SnackbarErrorAction('APP.MESSAGES.ERRORS.GENERIC')
expect(createFromTemplateDialogService.success$.next).toHaveBeenCalledWith(
test_node.entry
);
}));
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 {
map,
withLatestFrom,
switchMap,
catchError,
debounceTime,
flatMap,
skipWhile
take,
catchError
} from 'rxjs/operators';
import { Store } from '@ngrx/store';
import {
FileFromTemplate,
CreateFileFromTemplate,
TemplateActionTypes,
getCurrentFolder,
@ -45,9 +45,9 @@ import {
import { CreateFileFromTemplateService } from '../../services/create-file-from-template.service';
import { AlfrescoApiService } from '@alfresco/adf-core';
import { ContentManagementService } from '../../services/content-management.service';
import { from, of, Observable } from 'rxjs';
import { NodeEntry, NodeBodyUpdate, MinimalNode } from '@alfresco/js-api';
import { from, Observable, of } from 'rxjs';
import { NodeEntry, NodeBodyUpdate, Node } from '@alfresco/js-api';
import { CreateFromTemplateDialogService } from '../../dialogs/node-templates/create-from-template-dialog.service';
@Injectable()
export class TemplateEffects {
constructor(
@ -55,12 +55,13 @@ export class TemplateEffects {
private store: Store<AppStore>,
private apiService: AlfrescoApiService,
private actions$: Actions,
private createFromTemplateDialogService: CreateFromTemplateDialogService,
private createFileFromTemplateService: CreateFileFromTemplateService
) {}
@Effect({ dispatch: false })
fileFromTemplate$ = this.actions$.pipe(
ofType<CreateFileFromTemplate>(TemplateActionTypes.CreateFileFromTemplate),
ofType<FileFromTemplate>(TemplateActionTypes.FileFromTemplate),
map(() => {
this.createFileFromTemplateService
.openTemplatesDialog()
@ -70,15 +71,7 @@ export class TemplateEffects {
this.createFileFromTemplateService
.createTemplateDialog(node)
.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) => {
if (node) {
@ -88,10 +81,27 @@ export class TemplateEffects {
})
);
private copyNode(
source: MinimalNode,
parentId: string
): Observable<NodeEntry> {
@Effect({ dispatch: false })
createFileFromTemplate$ = this.actions$.pipe(
ofType<CreateFileFromTemplate>(TemplateActionTypes.CreateFileFromTemplate),
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(
this.apiService.getInstance().nodes.copyNode(source.id, {
targetParentId: parentId,
@ -99,25 +109,36 @@ export class TemplateEffects {
})
).pipe(
switchMap(node =>
this.updateNode(node.entry.id, {
this.updateNode(node, {
properties: {
'cm:title': source.properties['cm:title'],
'cm:description': source.properties['cm:description']
}
})
)
),
catchError(error => {
return this.handleError(error);
})
);
}
private updateNode(
id: string,
node: NodeEntry,
update: NodeBodyUpdate
): 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> {
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) {
this.store.dispatch(

View File

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