Enables file uploading. Refactors file dialog.

This commit is contained in:
Aaron Louie 2020-03-25 23:31:11 -04:00
parent c0c7b48c57
commit 616d2ad0d8
12 changed files with 176 additions and 131 deletions

6
package-lock.json generated
View File

@ -12171,9 +12171,9 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"sartography-workflow-lib": {
"version": "0.0.74",
"resolved": "https://registry.npmjs.org/sartography-workflow-lib/-/sartography-workflow-lib-0.0.74.tgz",
"integrity": "sha512-v8Kz89ta85O1nO7ICtJnok0LbxwEofwcqG2eivmo78xR4qYGisUl68GUTYOHdctAQwNdNbyUW7DxkqpkaPb8ew=="
"version": "0.0.78",
"resolved": "https://registry.npmjs.org/sartography-workflow-lib/-/sartography-workflow-lib-0.0.78.tgz",
"integrity": "sha512-8z27+0hL4ZpnTpA6nHQUzQsspr+1d/y2T5oXtX3BwLj+YDvbWMcO3hzw7BfJnAqanxszNPjcdoFZatWaNPYb5w=="
},
"sass": {
"version": "1.23.3",

View File

@ -49,7 +49,7 @@
"ngx-file-drop": "^8.0.8",
"ngx-markdown": "^9.0.0",
"rxjs": "~6.5.4",
"sartography-workflow-lib": "^0.0.74",
"sartography-workflow-lib": "^0.0.78",
"tslib": "^1.11.1",
"uuid": "^7.0.2",
"zone.js": "^0.10.3"

View File

@ -5,7 +5,6 @@
[fields]="fields"
[options]="options"
[form]="form"
(modelChange)="onModelChange($event)"
></formly-form>
</form>
</div>

View File

@ -1,8 +1,8 @@
import {Component, Inject} from '@angular/core';
import {AfterViewInit, Component, Inject, ViewChild} from '@angular/core';
import {FormControl, FormGroup} from '@angular/forms';
import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
import {FormlyFieldConfig, FormlyFormOptions} from '@ngx-formly/core';
import {cleanUpFilename, FileType} from 'sartography-workflow-lib';
import {cleanUpFilename, FileType, FileFieldComponent, ApiService} from 'sartography-workflow-lib';
import {FileMetaDialogData} from '../../_interfaces/dialog-data';
@Component({
@ -18,7 +18,7 @@ export class FileMetaDialogComponent {
constructor(
public dialogRef: MatDialogRef<FileMetaDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: FileMetaDialogData
@Inject(MAT_DIALOG_DATA) public data: FileMetaDialogData,
) {
const fileTypeOptions = Object.entries(FileType).map(ft => {
return {
@ -51,18 +51,6 @@ export class FileMetaDialogComponent {
options: fileTypeOptions,
},
},
{
key: 'file',
type: 'file',
defaultValue: this.data.file,
templateOptions: {
label: 'File',
required: true,
},
modelOptions: {
updateOn: 'change'
},
}
];
}
@ -74,17 +62,4 @@ export class FileMetaDialogComponent {
this.model.fileName = cleanUpFilename(this.model.fileName, this.model.fileType);
this.dialogRef.close(this.model);
}
onModelChange(model: any) {
console.log('model', model);
if (model.file && typeof model.file === 'object' && model.file instanceof Blob) {
// Upload file
const fileReader = new (window as any).FileReader();
fileReader.onload = (event: ProgressEvent) => {
const stringContent = (event.target as FileReader).result.toString();
console.log(stringContent);
};
fileReader.readAsText(model.file);
}
}
}

View File

@ -1,7 +1,7 @@
<div mat-dialog-title>
<ng-container *ngIf="!mode">Where is your file?</ng-container>
<ng-container *ngIf="mode === 'local'">Upload a local BPMN/DMN file</ng-container>
<ng-container *ngIf="mode === 'remote'">Open a BPMN/DMN file from URL</ng-container>
<ng-container *ngIf="mode === 'local'">Upload a local {{fileTypesString()}} file</ng-container>
<ng-container *ngIf="mode === 'remote'">Open a {{fileTypesString()}} file from URL</ng-container>
<span fxFlex></span>
<button mat-icon-button mat-dialog-close=""><mat-icon>close</mat-icon></button>
</div>
@ -9,11 +9,11 @@
<div *ngIf="!mode" class="select-mode" fxLayoutAlign="center center">
<button (click)="mode = 'local'" color="primary" mat-flat-button>
<mat-icon>code</mat-icon>
Upload a local BPMN/DMN file
Upload a local {{fileTypesString()}} file
</button>
<button (click)="mode = 'remote'" color="primary" mat-flat-button>
<mat-icon>link</mat-icon>
Open a BPMN/DMN file from URL
Open a {{fileTypesString()}} file from URL
</button>
</div>
@ -22,18 +22,18 @@
<span matPrefix><mat-icon>folder_open</mat-icon> &nbsp;</span>
<input [value]="getFileName()" disabled matInput type="text">
</mat-form-field>
<input #fileInput (change)="onFileSelected($event)" accept=".bpmn,.dmn,.xml,application/xml,text/xml" hidden id="file"
<input #fileInput (change)="onFileSelected($event)" accept="{{fileExtensions()}}" hidden id="file"
type="file">
<button (click)="onSubmit()" [disabled]="!diagramFile" color="primary" mat-flat-button>Upload File</button>
<button (click)="mode = undefined" mat-flat-button>Cancel</button>
<button (click)="onSubmit()" [disabled]="!data.file" color="primary" mat-flat-button>Upload File</button>
<button (click)="cancel()" mat-flat-button>Cancel</button>
</div>
<div *ngIf="mode === 'remote'">
<mat-form-field>
<span matPrefix><mat-icon>link</mat-icon> &nbsp;</span>
<input [(ngModel)]="url" matInput type="url" placeholder="BPMN/DMN File URL">
<input [(ngModel)]="url" matInput type="url" placeholder="{{fileTypesString()}} File URL">
</mat-form-field>
<button (click)="onSubmitUrl()" [disabled]="!isValidUrl()" color="primary" mat-flat-button>Fetch File</button>
<button (click)="mode = undefined" mat-flat-button>Cancel</button>
<button (click)="cancel()" mat-flat-button>Cancel</button>
</div>
</div>

View File

@ -71,18 +71,16 @@ describe('OpenFileDialogComponent', () => {
it('should save data on submit', () => {
const closeSpy = spyOn(component.dialogRef, 'close').and.stub();
const expectedData: OpenFileDialogData = { file: mockFileMeta0.file };
component.diagramFile = expectedData.file;
component.data.file = mockFileMeta0.file;
component.onSubmit();
expect(closeSpy).toHaveBeenCalledWith(expectedData);
expect(closeSpy).toHaveBeenCalledWith(component.data);
});
it('should not change data on cancel', () => {
const closeSpy = spyOn(component.dialogRef, 'close').and.stub();
const expectedData: OpenFileDialogData = { file: mockFileMeta0.file };
component.diagramFile = expectedData.file;
component.data.file = expectedData.file;
component.onNoClick();
expect(closeSpy).toHaveBeenCalledWith();
});
@ -97,23 +95,23 @@ describe('OpenFileDialogComponent', () => {
const req = httpMock.expectOne(url);
expect(req.request.method).toEqual('GET');
req.flush('<xml></xml>');
expect(component.diagramFile).toBeTruthy();
expect(component.diagramFile.name).toEqual(expectedName);
expect(component.data.file).toBeTruthy();
expect(component.data.file.name).toEqual(expectedName);
expect(onSubmitSpy).toHaveBeenCalled();
});
it('should get the diagram file name', () => {
component.diagramFile = undefined;
component.data.file = undefined;
expect(component.getFileName()).toEqual('Click to select a file');
component.diagramFile = mockFileMeta0.file;
component.data.file = mockFileMeta0.file;
expect(component.getFileName()).toEqual(mockFileMeta0.file.name);
});
it('should get a file from the file input field event', () => {
const event = {target: {files: [mockFileMeta0.file]}};
(component as any).onFileSelected(event);
expect(component.diagramFile).toEqual(mockFileMeta0.file);
expect(component.data.file).toEqual(mockFileMeta0.file);
});
it('should determine if a string is a valid URL', () => {

View File

@ -1,6 +1,7 @@
import {Component} from '@angular/core';
import {MatDialogRef} from '@angular/material/dialog';
import {ApiService, cleanUpFilename, getDiagramTypeFromXml} from 'sartography-workflow-lib';
import {Component, Inject} from '@angular/core';
import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
import {ApiService, cleanUpFilename, FileType, getDiagramTypeFromXml} from 'sartography-workflow-lib';
import {OpenFileDialogData} from '../../_interfaces/dialog-data';
@Component({
selector: 'app-open-file-dialog',
@ -9,13 +10,20 @@ import {ApiService, cleanUpFilename, getDiagramTypeFromXml} from 'sartography-wo
})
export class OpenFileDialogComponent {
mode: string;
diagramFile: File;
url: string;
fileTypes: FileType[];
fileMetaId: number;
constructor(
public dialogRef: MatDialogRef<OpenFileDialogComponent>,
private api: ApiService
private api: ApiService,
@Inject(MAT_DIALOG_DATA) public data: OpenFileDialogData,
) {
if (this.data) {
this.mode = this.data.mode || undefined;
this.fileTypes = this.data.fileTypes;
this.fileMetaId = this.data.fileMetaId;
}
}
onNoClick() {
@ -23,15 +31,15 @@ export class OpenFileDialogComponent {
}
onSubmit() {
this.dialogRef.close({file: this.diagramFile});
this.dialogRef.close(this.data);
}
onFileSelected($event: Event) {
this.diagramFile = ($event.target as HTMLFormElement).files[0];
this.data.file = ($event.target as HTMLFormElement).files[0];
}
getFileName() {
return this.diagramFile ? this.diagramFile.name : 'Click to select a file';
return this.data.file ? this.data.file.name : 'Click to select a file';
}
onSubmitUrl() {
@ -41,7 +49,7 @@ export class OpenFileDialogComponent {
const fileName = fileArray[fileArray.length - 1];
const fileType = getDiagramTypeFromXml(s);
const name = cleanUpFilename(fileName, fileType);
this.diagramFile = new File([s], name, {type: 'text/xml'});
this.data.file = new File([s], name, {type: 'text/xml'});
this.onSubmit();
});
}
@ -52,4 +60,24 @@ export class OpenFileDialogComponent {
const re = /^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!10(?:\.\d{1,3}){3})(?!127(?:\.\d{1,3}){3})(?!169\.254(?:\.\d{1,3}){2})(?!192\.168(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$/i;
return re.test(this.url);
}
fileTypesString(): string {
if (this.fileTypes && (this.fileTypes.length > 0)) {
return this.fileTypes.map(t => t.toString().toUpperCase()).join('/');
}
}
fileExtensions(): string {
if (this.fileTypes && (this.fileTypes.length > 0)) {
return this.fileTypes.map(t => '.' + t.toString()).join(',');
}
}
cancel() {
if (this.data.mode) {
this.onNoClick();
} else {
this.mode = undefined;
}
}
}

View File

@ -12,7 +12,10 @@ export interface NewFileDialogData {
}
export interface OpenFileDialogData {
fileMetaId?: number;
file: File;
mode?: string;
fileTypes?: FileType[];
}
export interface WorkflowSpecDialogData {

View File

@ -18,6 +18,9 @@
</ng-container>
<h4 (click)="editFile(fm)" mat-line>{{fm.name}}</h4>
<p (click)="editFile(fm)" mat-line>{{fm.last_updated | date}}</p>
<button (click)="downloadFile(fm)" class="mat-elevation-z0" color="primary" mat-icon-button>
<mat-icon>save_alt</mat-icon>
</button>
<button (click)="editFile(fm)" class="mat-elevation-z0" color="primary" mat-icon-button>
<mat-icon>edit</mat-icon>
</button>

View File

@ -179,30 +179,56 @@ describe('FileListComponent', () => {
expect(editFileMetaSpy).toHaveBeenCalledWith(null);
});
it('should change route and then open file metadata dialog', () => {
const routerNavigateSpy = spyOn((component as any).router, 'navigate')
.and.returnValue({finally: finallyCallback => finallyCallback()});
it('should open file metadata dialog', () => {
const _openFileDialogSpy = spyOn((component as any), '_openFileDialog').and.stub();
component.workflowSpec = mockWorkflowSpec0;
const mockDocMeta = createClone()(mockFileMeta0);
const mockDocMeta: FileMeta = createClone()(mockFileMeta0);
mockDocMeta.type = FileType.DOCX;
component.editFileMeta(mockDocMeta);
expect(routerNavigateSpy).toHaveBeenCalled();
const fakeBlob = new Blob(['I am a fake blob. A real blob says "blorp blorp blorp."']);
const expectedFile = new File([fakeBlob], mockDocMeta.name, {type: mockDocMeta.content_type});
const fReq = httpMock.expectOne(`apiRoot/file/${mockDocMeta.id}/data`);
expect(fReq.request.method).toEqual('GET');
fReq.flush(fakeBlob);
expect(_openFileDialogSpy).toHaveBeenCalledWith(mockDocMeta, fakeBlob);
expect(_openFileDialogSpy).toHaveBeenCalledWith(mockDocMeta, expectedFile);
routerNavigateSpy.calls.reset();
_openFileDialogSpy.calls.reset();
component.editFileMeta(null);
expect(routerNavigateSpy).toHaveBeenCalled();
expect(_openFileDialogSpy).toHaveBeenCalledWith();
});
it('should upload new file from file dialog', () => {
const openDialogSpy = spyOn(component.dialog, 'open')
.and.returnValue({afterClosed: () => of({file: mockFileMeta0.file})} as any);
const _loadFileMetasSpy = spyOn((component as any), '_loadFileMetas').and.stub();
component.workflowSpec = mockWorkflowSpec0;
(component as any)._openFileDialog();
const addReq = httpMock.expectOne(`apiRoot/file?workflow_spec_id=${mockWorkflowSpec0.id}`);
expect(addReq.request.method).toEqual('POST');
addReq.flush(mockFileMeta0);
expect(openDialogSpy).toHaveBeenCalled();
expect(_loadFileMetasSpy).toHaveBeenCalled();
});
it('should update existing file from file dialog', () => {
const openDialogSpy = spyOn(component.dialog, 'open')
.and.returnValue({afterClosed: () => of({fileMetaId: mockFileMeta0.id, file: mockFileMeta0.file})} as any);
const _loadFileMetasSpy = spyOn((component as any), '_loadFileMetas').and.stub();
component.workflowSpec = mockWorkflowSpec0;
(component as any)._openFileDialog(mockFileMeta0, mockFileMeta0.file);
const updateReq = httpMock.expectOne(`apiRoot/file/${mockFileMeta0.id}/data`);
expect(updateReq.request.method).toEqual('PUT');
updateReq.flush(mockFileMeta0);
expect(openDialogSpy).toHaveBeenCalled();
expect(_loadFileMetasSpy).toHaveBeenCalled();
});
it('should flag a file as primary', () => {
const updateFileMetaSpy = spyOn((component as any).api, 'updateFileMeta').and.returnValue(of(mockFileMeta0));
const _loadFileMetasSpy = spyOn((component as any), '_loadFileMetas').and.stub();

View File

@ -1,11 +1,20 @@
import {Component, Input, OnInit} from '@angular/core';
import {MatDialog} from '@angular/material/dialog';
import {MatSnackBar} from '@angular/material/snack-bar';
import {ActivatedRoute, Params, Router} from '@angular/router';
import {ApiService, FileMeta, FileParams, FileType, isNumberDefined, WorkflowSpec} from 'sartography-workflow-lib';
import {ActivatedRoute, Router} from '@angular/router';
import {
ApiService,
FileMeta,
FileParams,
FileType,
getFileType,
isNumberDefined,
WorkflowSpec
} from 'sartography-workflow-lib';
import {DeleteFileDialogComponent} from '../_dialogs/delete-file-dialog/delete-file-dialog.component';
import {FileMetaDialogComponent} from '../_dialogs/file-meta-dialog/file-meta-dialog.component';
import {DeleteFileDialogData, FileMetaDialogData} from '../_interfaces/dialog-data';
import {OpenFileDialogComponent} from '../_dialogs/open-file-dialog/open-file-dialog.component';
import {DeleteFileDialogData, OpenFileDialogData} from '../_interfaces/dialog-data';
import * as fileSaver from 'file-saver';
@Component({
selector: 'app-file-list',
@ -40,63 +49,14 @@ export class FileListComponent implements OnInit {
}
editFileMeta(fm: FileMeta) {
// Set route query string
this.router.navigate([], {
relativeTo: this.route,
fragment: this.workflowSpec.id,
queryParams: {
workflow_spec_id: this.workflowSpec.id
},
queryParamsHandling: 'merge'
}).finally(() => {
// Get file data
if (fm && isNumberDefined(fm.id)) {
this.api.getFileData(fm.id).subscribe(fileData => this._openFileDialog(fm, fileData));
} else {
this._openFileDialog();
}
});
}
private _openFileDialog(fm?: FileMeta, fileData?: Blob) {
const dialogRef = this.dialog.open(FileMetaDialogComponent, {
data: {
fileName: fm ? fm.name : undefined,
fileType: fm ? fm.type : undefined,
file: fileData ? fileData : undefined,
}
});
dialogRef.afterClosed().subscribe((data: FileMetaDialogData) => {
if (data && data.fileName && data.fileType) {
const newFileMeta: FileMeta = {
id: data.id,
content_type: data.fileType,
name: data.fileName,
type: data.fileType,
file: data.file,
};
if (isNumberDefined(data.id)) {
// Update existing file
this.api.updateFileMeta(newFileMeta).subscribe(() => {
this.api.updateFileData(newFileMeta).subscribe(() => {
this._loadFileMetas();
});
});
} else {
// Add new file
const fileParams: FileParams = {
workflow_spec_id: this.workflowSpec.id,
};
this.api.addFileMeta(fileParams, newFileMeta).subscribe(dbFm => {
this._loadFileMetas();
});
}
}
});
if (fm && isNumberDefined(fm.id)) {
this.api.getFileData(fm.id).subscribe(fileData => {
const file = new File([fileData], fm.name, {type: fm.content_type});
this._openFileDialog(fm, file);
});
} else {
this._openFileDialog();
}
}
confirmDelete(fm: FileMeta) {
@ -131,6 +91,49 @@ export class FileListComponent implements OnInit {
}
}
private _openFileDialog(fm?: FileMeta, file?: File) {
console.log('fm.id', fm && fm.id);
const dialogData: OpenFileDialogData = {
fileMetaId: fm ? fm.id : undefined,
file: file,
mode: 'local',
fileTypes: [FileType.DOCX],
};
const dialogRef = this.dialog.open(OpenFileDialogComponent, {data: dialogData});
dialogRef.afterClosed().subscribe((data: OpenFileDialogData) => {
if (data && data.file) {
const newFileMeta: FileMeta = {
id: data.fileMetaId,
content_type: data.file.type,
name: data.file.name,
type: getFileType(data.file),
file: data.file,
workflow_spec_id: this.workflowSpec.id,
};
console.log('data.fileMetaId', data.fileMetaId);
console.log('isNumberDefined(data.fileMetaId)', isNumberDefined(data.fileMetaId));
if (isNumberDefined(data.fileMetaId)) {
// Update existing file
this.api.updateFileData(newFileMeta).subscribe(() => {
this._loadFileMetas();
});
} else {
// Add new file
const fileParams: FileParams = {
workflow_spec_id: this.workflowSpec.id,
};
this.api.addFileMeta(fileParams, newFileMeta).subscribe(dbFm => {
this._loadFileMetas();
});
}
}
});
}
private _deleteFile(fileMeta: FileMeta) {
this.api.deleteFileMeta(fileMeta.id).subscribe(() => {
this._loadFileMetas();
@ -151,4 +154,10 @@ export class FileListComponent implements OnInit {
});
}
downloadFile(fm: FileMeta) {
this.api.getFileData(fm.id).subscribe(fileBlob => {
const blob = new Blob([fileBlob], {type: fm.content_type});
fileSaver.saveAs(blob, fm.name);
});
}
}

View File

@ -162,7 +162,11 @@ export class ModelerComponent implements AfterViewInit {
}
openFileDialog() {
const dialogRef = this.dialog.open(OpenFileDialogComponent, {});
const dialogData: OpenFileDialogData = {
file: undefined,
fileTypes: [FileType.DMN, FileType.BPMN],
};
const dialogRef = this.dialog.open(OpenFileDialogComponent, {data: dialogData});
dialogRef.afterClosed().subscribe((data: OpenFileDialogData) => {
if (data && data.file) {