From a2ec08f0d1811856f4d2636c68da31b163b3d252 Mon Sep 17 00:00:00 2001 From: Roman Letsuk Date: Wed, 20 Sep 2023 17:25:30 +0300 Subject: [PATCH 1/5] implemented files preparing to sync on submit event --- src/components/file/File.js | 571 ++++++++++++++++---------- src/providers/storage/s3.js | 8 +- src/sass/formio.form.scss | 9 + src/templates/bootstrap/file/form.ejs | 148 +++++++ 4 files changed, 512 insertions(+), 224 deletions(-) create mode 100644 src/templates/bootstrap/file/form.ejs diff --git a/src/components/file/File.js b/src/components/file/File.js index d08bf62274..a9c18138a7 100644 --- a/src/components/file/File.js +++ b/src/components/file/File.js @@ -95,8 +95,11 @@ export default class FileComponent extends Field { progress: progressSupported, }; this.cameraMode = false; - this.statuses = []; this.fileDropHidden = false; + this.filesToSync = { + filesToUpload: [], + filesToDelete: [], + }; } get dataReady() { @@ -154,10 +157,11 @@ export default class FileComponent extends Field { } render() { + const { filesToDelete, filesToUpload } = this.filesToSync; return super.render(this.renderTemplate('file', { fileSize: this.fileSize, files: this.dataValue || [], - statuses: this.statuses, + filesToSync: [...filesToDelete, ...filesToUpload], disabled: this.disabled, support: this.support, fileDropHidden: this.fileDropHidden @@ -235,7 +239,7 @@ export default class FileComponent extends Field { this.getFrame(videoPlayer) .then((frame) => { frame.name = `photo-${Date.now()}.png`; - this.upload([frame]); + this.prepareFilesToUpload([frame]); this.cameraMode = false; this.redraw(); }); @@ -330,24 +334,6 @@ export default class FileComponent extends Field { return options; } - deleteFile(fileInfo) { - const { options = {} } = this.component; - - if (fileInfo && (['url', 'indexeddb'].includes(this.component.storage))) { - const { fileService } = this; - if (fileService && typeof fileService.deleteFile === 'function') { - fileService.deleteFile(fileInfo, options); - } - else { - const formio = this.options.formio || (this.root && this.root.formio); - - if (formio) { - formio.makeRequest('', fileInfo.url, 'delete'); - } - } - } - } - attach(element) { this.loadRefs(element, { fileDrop: 'single', @@ -359,7 +345,7 @@ export default class FileComponent extends Field { videoPlayer: 'single', fileLink: 'multiple', removeLink: 'multiple', - fileStatusRemove: 'multiple', + fileToSyncRemove: 'multiple', fileImage: 'multiple', fileType: 'multiple', fileProcessingLoader: 'single', @@ -369,9 +355,9 @@ export default class FileComponent extends Field { const superAttach = super.attach(element); if (this.refs.fileDrop) { - if (!this.statuses.length) { - this.refs.fileDrop.removeAttribute('hidden'); - } + // if (!this.statuses.length) { + // this.refs.fileDrop.removeAttribute('hidden'); + // } const element = this; this.addEventListener(this.refs.fileDrop, 'dragover', function(event) { this.className = 'fileSelector fileDragOver'; @@ -384,7 +370,7 @@ export default class FileComponent extends Field { this.addEventListener(this.refs.fileDrop, 'drop', function(event) { this.className = 'fileSelector'; event.preventDefault(); - element.upload(event.dataTransfer.files); + element.prepareFilesToUpload(event.dataTransfer.files); }); } @@ -393,7 +379,7 @@ export default class FileComponent extends Field { event.preventDefault(); this.browseFiles(this.browseOptions) .then((files) => { - this.upload(files); + this.prepareFilesToUpload(files); }); }); } @@ -407,17 +393,14 @@ export default class FileComponent extends Field { this.refs.removeLink.forEach((removeLink, index) => { this.addEventListener(removeLink, 'click', (event) => { - const fileInfo = this.dataValue[index]; - - this.deleteFile(fileInfo); event.preventDefault(); - this.splice(index); - this.redraw(); + const fileInfo = this.dataValue[index]; + this.prepareFileToDelete(fileInfo); }); }); - this.refs.fileStatusRemove.forEach((fileStatusRemove, index) => { - this.addEventListener(fileStatusRemove, 'click', (event) => { + this.refs.fileToSyncRemove.forEach((fileToSyncRemove, index) => { + this.addEventListener(fileToSyncRemove, 'click', (event) => { event.preventDefault(); const fileUpload = this.statuses[index]; @@ -426,8 +409,7 @@ export default class FileComponent extends Field { if (fileUpload.abort) { fileUpload.abort(); } - - this.statuses.splice(index, 1); + this.filesToSync.filesToUpload.splice(index, 1); this.redraw(); }); }); @@ -442,7 +424,7 @@ export default class FileComponent extends Field { reader.onloadend = (evt) => { const blob = new Blob([new Uint8Array(evt.target.result)], { type: file.type }); blob.name = file.name; - this.upload([blob]); + this.prepareFilesToUpload([blob]); }; reader.readAsArrayBuffer(file); }); @@ -466,7 +448,7 @@ export default class FileComponent extends Field { reader.onloadend = (evt) => { const blob = new Blob([new Uint8Array(evt.target.result)], { type: file.type }); blob.name = file.name; - this.upload([blob]); + this.prepareFilesToUpload([blob]); }; reader.readAsArrayBuffer(file); }); @@ -636,12 +618,212 @@ export default class FileComponent extends Field { return file.size - 0.1 <= this.translateScalars(val); } - upload(files) { + getFileName(file) { + return uniqueName(file.name, this.component.fileNameTemplate, this.evalContext()); + } + + getInitFileToSync(file) { + const escapedFileName = file.name ? file.name.replaceAll('<', '<').replaceAll('>', '>') : file.name; + return { + // Get a unique name for this file to keep file collisions from occurring. + dir: this.interpolate(this.component.dir || ''), + name: this.getFileName(file), + originalName: escapedFileName, + fileKey: this.component.fileKey || 'file', + storage: this.component.storage, + options: this.component.options, + file, + size: file.size, + status: 'info', + message: this.t('Processing file. Please wait...'), + hash: '', + }; + } + + async handleSubmissionRevisions(file) { + if (this.root.form.submissionRevisions !== 'true') { + return ''; + } + + const bmf = new BMF(); + const hash = await new Promise((resolve, reject) => { + this.emit('fileUploadingStart'); + bmf.md5(file, (err, md5)=>{ + if (err) { + return reject(err); + } + return resolve(md5); + }); + }); + this.emit('fileUploadingEnd'); + + return hash; + } + + validateFileName(file) { + // Check if file with the same name is being uploaded + const fileWithSameNameUploading = this.filesToSync.filesToUpload + .some(fileToSync => fileToSync.file?.name === file.name); + + const fileWithSameNameUploaded = this.dataValue + .some(fileStatus => fileStatus.originalName === file.name); + + return fileWithSameNameUploaded || fileWithSameNameUploading + ? { + status: 'error', + message: this.t(`File with the same name is already ${fileWithSameNameUploading ? 'being ' : ''}uploaded`), + } + : {}; + } + + validateFileSettings(file) { + // Check file pattern + if (this.component.filePattern && !this.validatePattern(file, this.component.filePattern)) { + return { + status: 'error', + message: this.t('File is the wrong type; it must be {{ pattern }}', { + pattern: this.component.filePattern, + }), + }; + } + + // Check file minimum size + if (this.component.fileMinSize && !this.validateMinSize(file, this.component.fileMinSize)) { + return { + status: 'error', + message: this.t('File is too small; it must be at least {{ size }}', { + size: this.component.fileMinSize, + }), + }; + } + + // Check file maximum size + if (this.component.fileMaxSize && !this.validateMaxSize(file, this.component.fileMaxSize)) { + return { + status: 'error', + message: this.t('File is too big; it must be at most {{ size }}', { + size: this.component.fileMaxSize, + }), + }; + } + + return {}; + } + + validateFileService() { + const { fileService } = this; + return !fileService + ? { + status: 'error', + message: this.t('File Service not provided.'), + } + : {}; + } + + validateFile(file) { + const fileServiceValidation = this.validateFileService(); + if (fileServiceValidation.status === 'error') { + return fileServiceValidation; + } + + const fileNameValidation = this.validateFileName(file); + if (fileNameValidation.status === 'error') { + return fileNameValidation; + } + + return this.validateFileSettings(file); + } + + getGroupPermissions() { + let groupKey = null; + let groupPermissions = null; + + //Iterate through form components to find group resource if one exists + this.root.everyComponent((element) => { + if (element.component?.submissionAccess || element.component?.defaultPermission) { + groupPermissions = !element.component.submissionAccess ? [ + { + type: element.component.defaultPermission, + roles: [], + }, + ] : element.component.submissionAccess; + + groupPermissions.forEach((permission) => { + groupKey = ['admin', 'write', 'create'].includes(permission.type) ? element.component.key : null; + }); + } + }); + + return { groupKey, groupPermissions }; + } + + async triggerFileProcessor(file) { + let processedFile = null; + + if (this.root.options.fileProcessor) { + try { + if (this.refs.fileProcessingLoader) { + this.refs.fileProcessingLoader.style.display = 'block'; + } + const fileProcessorHandler = fileProcessor(this.fileService, this.root.options.fileProcessor); + processedFile = await fileProcessorHandler(file, this.component.properties); + } + catch (err) { + this.fileDropHidden = false; + return { + status: 'error', + message: this.t('File processing has been failed.'), + }; + } + finally { + if (this.refs.fileProcessingLoader) { + this.refs.fileProcessingLoader.style.display = 'none'; + } + } + } + + return { + file: processedFile, + }; + } + + async prepareFileToUpload(file) { + const fileToSync = this.getInitFileToSync(file); + fileToSync.hash = await this.handleSubmissionRevisions(file); + + const { status, message } = this.validateFile(file); + if (status === 'error') { + fileToSync.status = status; + fileToSync.message = message; + return this.filesToSync.filesToUpload.push(fileToSync); + } + + if (this.component.privateDownload) { + file.private = true; + } + + const { groupKey, groupPermissions } = this.getGroupPermissions(); + + const processedFile = await this.triggerFileProcessor(file); + if (processedFile.status === 'error') { + fileToSync.status === 'error'; + fileToSync.message = processedFile.message; + return this.filesToSync.filesToUpload.push(fileToSync); + } + + this.filesToSync.filesToUpload.push({ + ...fileToSync, + message: fileToSync.message = this.t('Ready to be uploaded into storage'), + file: processedFile.file || file, + url: this.interpolate(this.component.url, { file: fileToSync }), + groupPermissions, + groupResourceId: groupKey ? this.currentForm.submission.data[groupKey]._id : null, + }); + } + + prepareFilesToUpload(files) { // Only allow one upload if not multiple. if (!this.component.multiple) { - if (this.statuses.length) { - this.statuses = []; - } files = Array.prototype.slice.call(files, 0, 1); } @@ -649,203 +831,138 @@ export default class FileComponent extends Field { this.fileDropHidden = true; // files is not really an array and does not have a forEach method, so fake it. - /* eslint-disable max-statements */ Array.prototype.forEach.call(files, async(file) => { - const fileName = uniqueName(file.name, this.component.fileNameTemplate, this.evalContext()); - const escapedFileName = file.name ? file.name.replaceAll('<', '<').replaceAll('>', '>') : file.name; - const fileUpload = { - abort: () => null, - originalName: escapedFileName, - name: fileName, - size: file.size, - status: 'info', - message: this.t('Processing file. Please wait...'), - hash: '', - }; + await this.prepareFileToUpload(file); + this.redraw(); + }); + } + } - if (this.root.form.submissionRevisions === 'true') { - this.statuses.push(fileUpload); - this.redraw(); - const bmf = new BMF(); - const hash = await new Promise((resolve, reject) => { - this.emit('fileUploadingStart'); - bmf.md5(file, (err, md5)=>{ - if (err) { - return reject(err); - } - return resolve(md5); - }); - }); - this.emit('fileUploadingEnd'); - fileUpload.hash = hash; - } + prepareFileToDelete(fileInfo) { + this.filesToSync.filesToDelete.push({ + ...fileInfo, + status: 'readyToDelete', + message: this.t('Ready to be removed from storage'), + }); - // Check if file with the same name is being uploaded - if (!this.filesUploading) { - this.filesUploading = []; - } - const fileWithSameNameUploading = this.filesUploading.some(fileUploading => fileUploading === file.name); - this.filesUploading.push(file.name); - - const fileWithSameNameUploaded = this.dataValue.some(fileStatus => fileStatus.originalName === file.name); - const fileWithSameNameUploadedWithError = this.statuses.findIndex(fileStatus => - fileStatus.originalName === file.name - && fileStatus.status === 'error' - ); - - if (fileWithSameNameUploaded || fileWithSameNameUploading) { - fileUpload.status = 'error'; - fileUpload.message = this.t(`File with the same name is already ${fileWithSameNameUploading ? 'being ' : ''}uploaded`); - } + const index = this.dataValue.findIndex(file => file.name === fileInfo.name); + this.splice(index); + this.redraw(); + } - if (fileWithSameNameUploadedWithError !== -1) { - this.statuses.splice(fileWithSameNameUploadedWithError, 1); - this.redraw(); - } + deleteFile(fileInfo) { + const { options = {} } = this.component; - // Check file pattern - if (this.component.filePattern && !this.validatePattern(file, this.component.filePattern)) { - fileUpload.status = 'error'; - fileUpload.message = this.t('File is the wrong type; it must be {{ pattern }}', { - pattern: this.component.filePattern, - }); - } - // Check file minimum size - if (this.component.fileMinSize && !this.validateMinSize(file, this.component.fileMinSize)) { - fileUpload.status = 'error'; - fileUpload.message = this.t('File is too small; it must be at least {{ size }}', { - size: this.component.fileMinSize, - }); - } + if (fileInfo && (['url', 'indexeddb', 's3'].includes(this.component.storage))) { + const { fileService } = this; + if (fileService && typeof fileService.deleteFile === 'function') { + fileService.deleteFile(fileInfo, options); + } + else { + const formio = this.options.formio || (this.root && this.root.formio); - // Check file maximum size - if (this.component.fileMaxSize && !this.validateMaxSize(file, this.component.fileMaxSize)) { - fileUpload.status = 'error'; - fileUpload.message = this.t('File is too big; it must be at most {{ size }}', { - size: this.component.fileMaxSize, - }); + if (formio) { + formio.makeRequest('', fileInfo.url, 'delete'); } + } + } + } - // Get a unique name for this file to keep file collisions from occurring. - const dir = this.interpolate(this.component.dir || ''); - const { fileService } = this; - if (!fileService) { - fileUpload.status = 'error'; - fileUpload.message = this.t('File Service not provided.'); - } + async delete() { + if (!this.filesToSync.filesToDelete.length) { + return NativePromise.resolve(); + } - if (this.root.form.submissionRevisions !== 'true') { - this.statuses.push(fileUpload); + const files = await Promise.all(this.filesToSync.filesToDelete.map((fileToDelete) => { + return new NativePromise(async(resolve) => { + try { + this.deleteFile(fileToDelete); + fileToDelete.status = 'success'; + fileToDelete.message = this.t('Succefully removed'); + this.redraw(); + resolve({ + status: 'success', + }); + } + catch (response) { + fileToDelete.status = 'error'; + fileToDelete.message = typeof response === 'string' ? response : response.toString(); this.redraw(); + resolve(fileToDelete); } + }); + })); - if (fileUpload.status !== 'error') { - if (this.component.privateDownload) { - file.private = true; - } - const { storage, options = {} } = this.component; - const url = this.interpolate(this.component.url, { file: fileUpload }); - let groupKey = null; - let groupPermissions = null; - - //Iterate through form components to find group resource if one exists - this.root.everyComponent((element) => { - if (element.component?.submissionAccess || element.component?.defaultPermission) { - groupPermissions = !element.component.submissionAccess ? [ - { - type: element.component.defaultPermission, - roles: [], - }, - ] : element.component.submissionAccess; - - groupPermissions.forEach((permission) => { - groupKey = ['admin', 'write', 'create'].includes(permission.type) ? element.component.key : null; - }); - } - }); - const fileKey = this.component.fileKey || 'file'; - const groupResourceId = groupKey ? this.currentForm.submission.data[groupKey]._id : null; - let processedFile = null; - - if (this.root.options.fileProcessor) { - try { - if (this.refs.fileProcessingLoader) { - this.refs.fileProcessingLoader.style.display = 'block'; - } - const fileProcessorHandler = fileProcessor(this.fileService, this.root.options.fileProcessor); - processedFile = await fileProcessorHandler(file, this.component.properties); - } - catch (err) { - fileUpload.status = 'error'; - fileUpload.message = this.t('File processing has been failed.'); - this.fileDropHidden = false; - this.redraw(); - return; - } - finally { - if (this.refs.fileProcessingLoader) { - this.refs.fileProcessingLoader.style.display = 'none'; - } - } - } + if (files.filter(file => file.status === 'error').length) { + return NativePromise.reject(); + } - fileUpload.message = this.t('Starting upload...'); - this.redraw(); + // this.filesToSync.filesToDelete = []; + return NativePromise.resolve(); + } + + async uploadFile(fileToSync) { + return await this.fileService.uploadFile( + fileToSync.storage, + fileToSync.file, + fileToSync.name, + fileToSync.dir, + // Progress callback + (evt) => { + fileToSync.status = 'progress'; + fileToSync.progress = parseInt(100.0 * evt.loaded / evt.total); + delete fileToSync.message; + this.redraw(); + }, + fileToSync.url, + fileToSync.options, + fileToSync.fileKey, + fileToSync.groupPermissions, + fileToSync.groupResourceId, + () => {}, + // Abort upload callback + (abort) => this.abortUpload = abort, + ); + } + + async upload() { + if (!this.filesToSync.filesToUpload.length) { + return NativePromise.resolve(); + } + + const files = await Promise.all(this.filesToSync.filesToUpload.map((fileToSync) => { + return new NativePromise(async(resolve) => { + try { + const fileInfo = await this.uploadFile(fileToSync); + fileToSync.status = 'success'; + fileToSync.message = this.t('Succefully uploaded'); - const filePromise = fileService.uploadFile( - storage, - processedFile || file, - fileName, - dir, - // Progress callback - (evt) => { - fileUpload.status = 'progress'; - fileUpload.progress = parseInt(100.0 * evt.loaded / evt.total); - delete fileUpload.message; - this.redraw(); - }, - url, - options, - fileKey, - groupPermissions, - groupResourceId, - // Upload start callback - () => { - this.emit('fileUploadingStart', filePromise); - }, - (abort) => fileUpload.abort = abort, - ).then((fileInfo) => { - const index = this.statuses.indexOf(fileUpload); - if (index !== -1) { - this.statuses.splice(index, 1); - } - fileInfo.originalName = escapedFileName; - fileInfo.hash = fileUpload.hash; - if (!this.hasValue()) { - this.dataValue = []; - } - this.dataValue.push(fileInfo); - _.pull(this.filesUploading, fileInfo.originalName); - this.fileDropHidden = false; - this.redraw(); - this.triggerChange(); - this.emit('fileUploadingEnd', filePromise); - }) - .catch((response) => { - fileUpload.status = 'error'; - fileUpload.message = typeof response === 'string' ? response : response.toString(); - delete fileUpload.progress; - this.fileDropHidden = false; - _.pull(this.filesUploading, file.name); - this.redraw(); - this.emit('fileUploadingEnd', filePromise); - }); + fileInfo.originalName = fileToSync.originalName; + fileInfo.hash = fileToSync.hash; + this.redraw(); + resolve(fileInfo); } - else { - this.filesUploading.splice(this.filesUploading.indexOf(file.name),1); + catch (response) { + fileToSync.status = 'error'; + fileToSync.message = typeof response === 'string' ? response : response.toString(); + delete fileToSync.progress; + this.redraw(); + resolve(fileToSync); } }); + })); + + if (files.filter(file => file.status === 'error').length) { + return NativePromise.reject(); + } + + if (!this.hasValue()) { + this.dataValue = []; } + this.dataValue.push(...files); + this.filesToSync.filesToUpload = []; + this.triggerChange(); + return NativePromise.resolve(); } getFile(fileInfo) { @@ -884,7 +1001,17 @@ export default class FileComponent extends Field { } } - destroy(all = false) { + async beforeSubmit() { + try { + await NativePromise.all([this.delete(), this.upload()]); + return NativePromise.resolve(); + } + catch (err) { + return NativePromise.reject(); + } + } + + destroy(all) { this.stopVideo(); super.destroy(all); } diff --git a/src/providers/storage/s3.js b/src/providers/storage/s3.js index 0963e3c57a..a2fd698c1c 100644 --- a/src/providers/storage/s3.js +++ b/src/providers/storage/s3.js @@ -40,8 +40,12 @@ const s3 = (formio) => ({ else { return Promise.resolve(file); } - } - }); + }, + deleteFile(fileInfo) { + const url = `${formio.formUrl}/file/${XHR.trim(fileInfo.name)}?bucket=${XHR.trim(fileInfo.bucket)}&key=${XHR.trim(fileInfo.key)}`; + return formio.makeRequest('', url, 'delete'); + }, +}); s3.title = 'S3'; export default s3; diff --git a/src/sass/formio.form.scss b/src/sass/formio.form.scss index dea8b35bcb..6650c26fde 100644 --- a/src/sass/formio.form.scss +++ b/src/sass/formio.form.scss @@ -380,6 +380,11 @@ td > .formio-form-group { } } +.formio-component-file .status { + margin-top: 4px; + font-size: 0.9rem; +} + .formio-component-file .fileSelector.fileDragOver { border-color: #127abe; } @@ -1765,3 +1770,7 @@ span[role="link"] { .hidden { display: none !important; } + +.align-center { + align-items: center; +} diff --git a/src/templates/bootstrap/file/form.ejs b/src/templates/bootstrap/file/form.ejs new file mode 100644 index 0000000000..45cf2f6fa2 --- /dev/null +++ b/src/templates/bootstrap/file/form.ejs @@ -0,0 +1,148 @@ +{% if (ctx.options.vpat) { %} + +{% } %} +{% if (!ctx.self.imageUpload) { %} + {% if (ctx.options.vpat) { %} +
{{(!ctx.component.filePattern || ctx.component.filePattern === '*') ? 'Any file types are allowed' : ctx.t('Allowed file types: ') + ctx.component.filePattern}}
+ {% } %} + +{% } else { %} +
+ {% ctx.files.forEach(function(file) { %} +
+ + {{file.originalName || file.name}} + {% if (!ctx.disabled) { %} + + {% } %} + +
+ {% }) %} +
+{% } %} +{% if (!ctx.disabled && (ctx.component.multiple || !ctx.files.length)) { %} + {% if (ctx.self.useWebViewCamera) { %} +
+ + +
+ {% } else if (!ctx.self.cameraMode) { %} +
+ {{ctx.t('Drop files to attach,')}} + {% if (ctx.self.imageUpload && ctx.component.webcam) { %} + {{ctx.t('use camera')}} + {% } %} + {{ctx.t('or')}} + + {{ctx.t('browse')}} + + {{ctx.t('Browse to attach file for ' + ctx.component.label + '. ' + + (ctx.component.description ? ctx.component.description + '. ' : '') + + ((!ctx.component.filePattern || ctx.component.filePattern === '*') ? 'Any file types are allowed' : ctx.t('Allowed file types: ') + ctx.component.filePattern))}} + + +
+
+
+
+ {% } else { %} +
+ +
+ + + {% } %} +{% } %} +{% if (!ctx.component.storage || ctx.support.hasWarning) { %} +
+ {% if (!ctx.component.storage) { %} +

{{ctx.t('No storage has been set for this field. File uploads are disabled until storage is set up.')}}

+ {% } %} + {% if (!ctx.support.filereader) { %} +

{{ctx.t('File API & FileReader API not supported.')}}

+ {% } %} + {% if (!ctx.support.formdata) { %} +

{{ctx.t("XHR2's FormData is not supported.")}}

+ {% } %} + {% if (!ctx.support.progress) { %} +

{{ctx.t("XHR2's upload progress isn't supported.")}}

+ {% } %} +
+{% } %} From 44ef260113b74e81f6772aff32cd7b171a3bdf61 Mon Sep 17 00:00:00 2001 From: Roman Letsuk Date: Fri, 29 Sep 2023 16:02:21 +0300 Subject: [PATCH 2/5] added ability to manually synchronize files --- src/Webform.js | 5 +- src/components/file/File.js | 205 ++++++++++++++---- .../file/editForm/File.edit.validation.js | 8 + src/sass/formio.form.scss | 10 + src/templates/bootstrap/file/form.ejs | 59 +++-- 5 files changed, 223 insertions(+), 64 deletions(-) diff --git a/src/Webform.js b/src/Webform.js index 4d915badb7..7e1acb95d6 100644 --- a/src/Webform.js +++ b/src/Webform.js @@ -891,7 +891,10 @@ export default class Webform extends NestedDataComponent { this.addComponents(); this.on('submitButton', options => { - this.submit(false, options).catch(e => e !== false && console.log(e)); + this.submit(false, options).catch(e => { + options.instance.loading = false; + return e !== false && console.log(e); + }); }, true); this.on('checkValidity', (data) => this.checkValidity(data, true, data), true); diff --git a/src/components/file/File.js b/src/components/file/File.js index a9c18138a7..693428a340 100644 --- a/src/components/file/File.js +++ b/src/components/file/File.js @@ -100,6 +100,8 @@ export default class FileComponent extends Field { filesToUpload: [], filesToDelete: [], }; + this.isSyncing = false; + this.abortUploads = []; } get dataReady() { @@ -156,15 +158,26 @@ export default class FileComponent extends Field { this._fileBrowseHidden = value; } + get shouldSyncFiles() { + return Boolean(this.filesToSync.filesToDelete.length || this.filesToSync.filesToUpload.length); + } + + get autoSync() { + return _.get(this, 'component.validate.autoSync', false); + } + render() { const { filesToDelete, filesToUpload } = this.filesToSync; return super.render(this.renderTemplate('file', { fileSize: this.fileSize, files: this.dataValue || [], - filesToSync: [...filesToDelete, ...filesToUpload], + filesToDelete, + filesToUpload, disabled: this.disabled, support: this.support, - fileDropHidden: this.fileDropHidden + fileDropHidden: this.fileDropHidden, + showSyncButton: (filesToDelete.length || filesToUpload.length) && !this.isSyncing, + isSyncing: this.isSyncing, })); } @@ -349,6 +362,9 @@ export default class FileComponent extends Field { fileImage: 'multiple', fileType: 'multiple', fileProcessingLoader: 'single', + syncNow: 'single', + restoreFile: 'multiple', + abortRequest: 'multiple', }); // Ensure we have an empty input refs. We need this for the setValue method to redraw the control when it is set. this.refs.input = []; @@ -402,14 +418,32 @@ export default class FileComponent extends Field { this.refs.fileToSyncRemove.forEach((fileToSyncRemove, index) => { this.addEventListener(fileToSyncRemove, 'click', (event) => { event.preventDefault(); + this.filesToSync.filesToUpload.splice(index, 1); + this.redraw(); + }); + }); - const fileUpload = this.statuses[index]; - _.pull(this.filesUploading, fileUpload.originalName); - - if (fileUpload.abort) { - fileUpload.abort(); + this.refs.abortRequest.forEach((abort, index) => { + this.addEventListener(abort, 'click', (event) => { + event.preventDefault(); + const fileInfo = this.filesToSync.filesToUpload[index]; + const abortUpload = this.abortUploads.find(abortUpload => abortUpload.name === fileInfo.name); + if (abortUpload) { + abortUpload.abort(); } - this.filesToSync.filesToUpload.splice(index, 1); + this.redraw(); + }); + }); + + this.refs.restoreFile.forEach((fileToRestore, index) => { + this.addEventListener(fileToRestore, 'click', (event) => { + event.preventDefault(); + const fileInfo = this.filesToSync.filesToDelete[index]; + delete fileInfo.status; + delete fileInfo.message; + this.filesToSync.filesToDelete.splice(index, 1); + this.dataValue.push(fileInfo); + this.triggerChange(); this.redraw(); }); }); @@ -493,6 +527,11 @@ export default class FileComponent extends Field { }); }); + this.addEventListener(this.refs.syncNow, 'click', (event) => { + event.preventDefault(); + this.syncFiles(); + }); + const fileService = this.fileService; if (fileService) { const loadingImages = []; @@ -841,7 +880,7 @@ export default class FileComponent extends Field { prepareFileToDelete(fileInfo) { this.filesToSync.filesToDelete.push({ ...fileInfo, - status: 'readyToDelete', + status: 'info', message: this.t('Ready to be removed from storage'), }); @@ -873,35 +912,37 @@ export default class FileComponent extends Field { return NativePromise.resolve(); } - const files = await Promise.all(this.filesToSync.filesToDelete.map((fileToDelete) => { + return await Promise.all(this.filesToSync.filesToDelete.map((fileToSync, i) => { return new NativePromise(async(resolve) => { try { - this.deleteFile(fileToDelete); - fileToDelete.status = 'success'; - fileToDelete.message = this.t('Succefully removed'); - this.redraw(); - resolve({ - status: 'success', - }); + if (i === 1) { + throw new Error('Delete error'); + } + this.deleteFile(fileToSync); + fileToSync.status = 'success'; + fileToSync.message = this.t('Succefully removed'); } catch (response) { - fileToDelete.status = 'error'; - fileToDelete.message = typeof response === 'string' ? response : response.toString(); + fileToSync.status = 'error'; + fileToSync.message = typeof response === 'string' ? response : response.toString(); + } + finally { this.redraw(); - resolve(fileToDelete); + resolve({ + fileToSync, + }); } }); })); + // if (files.filter(file => file.status === 'error').length) { + // return NativePromise.reject(); + // } - if (files.filter(file => file.status === 'error').length) { - return NativePromise.reject(); - } - - // this.filesToSync.filesToDelete = []; - return NativePromise.resolve(); + // // this.filesToSync.filesToDelete = []; + // return NativePromise.resolve(); } - async uploadFile(fileToSync) { + async uploadFile(fileToSync, abortIndex) { return await this.fileService.uploadFile( fileToSync.storage, fileToSync.file, @@ -921,7 +962,10 @@ export default class FileComponent extends Field { fileToSync.groupResourceId, () => {}, // Abort upload callback - (abort) => this.abortUpload = abort, + (abort) => this.abortUploads.push({ + name: fileToSync.name, + abort, + }), ); } @@ -930,39 +974,80 @@ export default class FileComponent extends Field { return NativePromise.resolve(); } - const files = await Promise.all(this.filesToSync.filesToUpload.map((fileToSync) => { + return await Promise.all(this.filesToSync.filesToUpload.map((fileToSync, i) => { return new NativePromise(async(resolve) => { + let fileInfo = null; try { - const fileInfo = await this.uploadFile(fileToSync); + if (i === 1) { + throw new Error('Upload error.'); + } + fileInfo = await this.uploadFile(fileToSync); fileToSync.status = 'success'; fileToSync.message = this.t('Succefully uploaded'); fileInfo.originalName = fileToSync.originalName; fileInfo.hash = fileToSync.hash; - this.redraw(); - resolve(fileInfo); } catch (response) { fileToSync.status = 'error'; fileToSync.message = typeof response === 'string' ? response : response.toString(); delete fileToSync.progress; + } + finally { + delete fileToSync.progress; this.redraw(); - resolve(fileToSync); + resolve({ + fileToSync, + fileInfo, + }); } }); })); - if (files.filter(file => file.status === 'error').length) { + // if (files.filter(file => file.status === 'error').length) { + // return NativePromise.reject(); + // } + + // if (!this.hasValue()) { + // this.dataValue = []; + // } + // this.dataValue.push(...files); + // this.filesToSync.filesToUpload = []; + // this.triggerChange(); + // return NativePromise.resolve(); + } + + async syncFiles() { + this.isSyncing = true; + this.redraw(); + try { + const [filesToDelete = [], filesToUpload = []] = await NativePromise.all([this.delete(), this.upload()]); + this.filesToSync.filesToDelete = filesToDelete + .filter(file => file.fileToSync?.status === 'error') + .map(file => file.fileToSync); + this.filesToSync.filesToUpload = filesToUpload + .filter(file => file.fileToSync?.status === 'error') + .map(file => file.fileToSync); + + if (!this.hasValue()) { + this.dataValue =[]; + } + + const data = filesToUpload + .filter(file => file.fileToSync?.status === 'success') + .map(file => file.fileInfo); + this.dataValue.push(...data); + this.triggerChange(); + return NativePromise.resolve(); + } + catch (err) { return NativePromise.reject(); } - - if (!this.hasValue()) { - this.dataValue = []; + finally { + this.isSyncing = false; + this.abortUploads = []; + this.redraw(); } - this.dataValue.push(...files); - this.filesToSync.filesToUpload = []; - this.triggerChange(); - return NativePromise.resolve(); } getFile(fileInfo) { @@ -1001,14 +1086,40 @@ export default class FileComponent extends Field { } } - async beforeSubmit() { - try { - await NativePromise.all([this.delete(), this.upload()]); - return NativePromise.resolve(); + checkComponentValidity(data, dirty, row, options = {}) { + const superValid = super.checkComponentValidity(data, dirty, row, options); + if (!superValid) { + return false; } - catch (err) { - return NativePromise.reject(); + + if (this.shouldSkipValidation(data, dirty, row)) { + return true; + } + + if (!this.autoSync && this.shouldSyncFiles) { + this.setCustomValidity('Files synchronization is required'); + return false; } + + return superValid; + } + + beforeSubmit() { + return new NativePromise(async(resolve, reject) => { + try { + if (!this.autoSync) { + return resolve(); + } + + await this.syncFiles(); + this.shouldSyncFiles + ? reject('Synchronization is failed') + : resolve(); + } + catch (error) { + reject(error.message); + } + }); } destroy(all) { diff --git a/src/components/file/editForm/File.edit.validation.js b/src/components/file/editForm/File.edit.validation.js index ed5bc10a65..2c1527db9e 100644 --- a/src/components/file/editForm/File.edit.validation.js +++ b/src/components/file/editForm/File.edit.validation.js @@ -1,4 +1,12 @@ export default [ + { + weight: 20, + type: 'checkbox', + label: 'Auto Files Sync', + tooltip: 'Files will be synced before submit.', + key: 'validate.autoSync', + input: true + }, { key: 'unique', ignore: true diff --git a/src/sass/formio.form.scss b/src/sass/formio.form.scss index 6650c26fde..fa5fdcd914 100644 --- a/src/sass/formio.form.scss +++ b/src/sass/formio.form.scss @@ -385,6 +385,10 @@ td > .formio-form-group { font-size: 0.9rem; } +.formio-component-file .list-group-item .fa { + cursor: pointer; +} + .formio-component-file .fileSelector.fileDragOver { border-color: #127abe; } @@ -1772,5 +1776,11 @@ span[role="link"] { } .align-center { + display: flex; align-items: center; } + +.justify-center { + display: flex; + justify-content: center; +} diff --git a/src/templates/bootstrap/file/form.ejs b/src/templates/bootstrap/file/form.ejs index 45cf2f6fa2..da5e85aab2 100644 --- a/src/templates/bootstrap/file/form.ejs +++ b/src/templates/bootstrap/file/form.ejs @@ -8,23 +8,24 @@