diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js index 908567f36e59b156f2cc6f66216cbd0d326c1308..e038065ac290f36af5dfffef843f24369df6fbdf 100644 --- a/vospace-ui-frontend/src/api/server/index.js +++ b/vospace-ui-frontend/src/api/server/index.js @@ -127,15 +127,25 @@ export default { }, false); }, uploadFile(url, file) { + let CancelToken = axios.CancelToken; + let source = CancelToken.source(); + let formData = new FormData(); formData.append('file', file); - return axios.put(url, formData, { + + let request = axios.put(url, formData, { headers: { 'Content-Type': 'multipart/form-data' - } + }, + cancelToken: source.token }); + + return { + request, + source + }; }, - deleteNodes(paths) { + deleteNodes(paths, showLoading = true) { let url = BASE_API_URL + 'delete'; return apiRequest({ method: 'POST', @@ -145,7 +155,7 @@ export default { 'Cache-Control': 'no-cache' }, data: paths - }, true, true); + }, showLoading, true); }, keepalive() { let url = BASE_API_URL + 'keepalive'; diff --git a/vospace-ui-frontend/src/components/modal/UploadFilesModal.vue b/vospace-ui-frontend/src/components/modal/UploadFilesModal.vue index 17d68b19e2138ac76e0a1c63e108d8659ec6a5fb..158e6b23940f90875ea88bde704f15d7314ba185 100644 --- a/vospace-ui-frontend/src/components/modal/UploadFilesModal.vue +++ b/vospace-ui-frontend/src/components/modal/UploadFilesModal.vue @@ -4,20 +4,46 @@ SPDX-License-Identifier: GPL-3.0-or-later --> <template> -<b-modal id="upload-files-modal" title="Upload file" okTitle="Upload" @show="reset" @ok.prevent="uploadFiles"> - <b-form-file v-model="files" :multiple="true" :state="fileState" placeholder="Choose your files or drop them here..." drop-placeholder="Drop files here..." @change="resetError"></b-form-file> +<b-modal id="upload-files-modal" title="Upload file" okTitle="Upload" @show="reset" @ok.prevent="uploadFiles" :no-close-on-backdrop="blockModal" :no-close-on-esc="blockModal" :hide-header-close="blockModal" :cancel-disabled="blockModal" + :ok-disabled="blockModal" size="lg"> + <b-form-file v-model="files" :multiple="true" :state="fileState" placeholder="Choose your files or drop them here..." drop-placeholder="Drop files here..." @change="selectionChanged"></b-form-file> <b-form-invalid-feedback id="upload-file-input-feedback" class="text-right">{{uploadFileError}}</b-form-invalid-feedback> - <div class="mt-3">Selected files: {{ selectedFiles }}</div> + <div class="mt-3" v-if="!blockModal">Selected files: {{ selectedFiles }}</div> + <div v-if="creatingMetadata" class="mt-3"> + <b-spinner small variant="primary" label="Spinning"></b-spinner> + Creating metadata... + </div> + <div v-if="uploadInProgress" class="mt-3"> + <div v-for="(file, index) in files" :key="index" class="upload-progress-container mt-1"> + <div v-if="!deletionStatuses[index]"> + {{ file.name }}<br /> + <span class="text-danger cancel-upload" @click="cancelUpload(index)">×</span> + <b-progress :value="progress[index]" :max="100" show-progress animated></b-progress> + </div> + <div v-if="deletionStatuses[index]"> + <b-spinner small variant="primary" label="Spinning"></b-spinner> + Upload of {{ file.name }} has been canceled. Waiting for metadata deletion... + </div> + </div> + </div> </b-modal> </template> <script> const maxUploadSize = process.env.VUE_APP_MAX_UPLOAD_SIZE; +import main from '../../main'; +import client from 'api-client'; +import Vue from 'vue'; export default { data() { return { - files: [], + files: [], // array of selected files + progress: [], // array of upload progress percentages (for tracking upload progress) + cancellations: [], // array of axios cancel tokens (for aborting uploads) + deletionStatuses: [], // status of upload deletions (true if request is being aborted, false otherwise) + creatingMetadata: false, // true when nodes metadata is being created (pre-upload phase) + uploadInProgress: false, // true when data is being uploaded uploadFileError: null } }, @@ -37,6 +63,9 @@ export default { names.push(file.name); } return names.join(', '); + }, + blockModal() { + return this.creatingMetadata || this.uploadInProgress; } }, methods: { @@ -47,7 +76,13 @@ export default { resetError() { this.uploadFileError = null; }, + selectionChanged() { + this.resetError(); + }, uploadFiles() { + if (this.uploadInProgress || this.creatingMetadata) { + return; + } if (this.files.length === 0) { this.uploadFileError = "Select at least one file"; } else { @@ -67,13 +102,89 @@ export default { } } + for (let i = 0; i < this.files.length; i++) { + let file = this.files[i]; + let reader = new FileReader(); + + reader.addEventListener('progress', (event) => { + if (event.loaded && event.total) { + let percent = (event.loaded / event.total) * 100; + this.progress[i] = percent; + } + }); + + reader.readAsDataURL(file); + } + + // reset status arrays + Vue.set(this, 'progress', []); + Vue.set(this, 'cancellations', []); + Vue.set(this, 'deletionStatuses', []); + + this.creatingMetadata = true; + // Upload this.$store.dispatch('uploadFiles', this.files) - .then(() => { + .then((uploads) => { + this.creatingMetadata = false; + + let promises = []; + for (let upload of uploads) { + promises.push(upload.request); + this.cancellations.push(upload.source); + this.deletionStatuses.push(false); + } + + this.uploadInProgress = true; + + // wait until all downloads have been completed (both successfully or not) + return Promise.allSettled(promises); + }) + .then((responses) => { + + let deletionPromises = []; + for (let i = 0; i < responses.length; i++) { + let response = responses[i]; + + if (response.status !== 'fulfilled') { + if (this.deletionStatuses[i]) { + deletionPromises.push(client.deleteNodes(['/' + this.$store.state.path + '/' + this.files[i].name], false)); + } else { + let message = "Unable to upload file " + this.files[i].name; + main.showError(message); + } + } + } + + return Promise.allSettled(deletionPromises); + }) + .finally(() => { + // Reload current node when all uploads completed + this.$store.dispatch('setPath', this.$store.state.path); this.$bvModal.hide('upload-files-modal'); + this.creatingMetadata = false; + this.uploadInProgress = false; }); } + }, + cancelUpload(index) { + Vue.set(this.deletionStatuses, index, true); + this.cancellations[index].cancel(); } } } </script> + +<style> +.upload-progress-container { + position: relative; +} + +.cancel-upload { + cursor: pointer; + position: absolute; + right: 3px; + top: 12px; + font-size: 170%; +} +</style> diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js index 236a773286a3cbc9db2c1710e0849986d5148f3e..63d0fc70715b88f626eaffaa044d3fd81ac5aef3 100644 --- a/vospace-ui-frontend/src/store.js +++ b/vospace-ui-frontend/src/store.js @@ -204,31 +204,22 @@ export default new Vuex.Store({ dispatch('setPath', state.path); }); }, - uploadFiles({ state, commit, dispatch }, files) { - commit('setLoading', true); + uploadFiles({ state }, files) { let names = []; for (let file of files) { names.push(file.name); } - return client.prepareForUpload(state.path, names) - .then(uploadUrls => { - let uploads = []; - for (let i = 0; i < files.length; i++) { - uploads.push(client.uploadFile(uploadUrls[i], files[i])); - } - Promise.all(uploads) - .then(() => { - // Reload current node when all uploads completed - dispatch('setPath', state.path); - }) - .catch(error => { - let message = "Unable to upload file" - if (error.response && error.response.data) { - message += ": " + error.response.data; - } - main.showError(message); - }); - }); + return new Promise((resolve, reject) => { + client.prepareForUpload(state.path, names) + .then(uploadUrls => { + let uploads = []; + for (let i = 0; i < files.length; i++) { + uploads.push(client.uploadFile(uploadUrls[i], files[i])); + } + resolve(uploads); + }) + .catch(error => reject(error)); + }); }, deleteNodes({ state, dispatch }) { client.deleteNodes(state.nodesToDelete)