diff --git a/app/assets/stylesheets/alchemy/upload.scss b/app/assets/stylesheets/alchemy/upload.scss index b6969158e8..9673ca6d7f 100644 --- a/app/assets/stylesheets/alchemy/upload.scss +++ b/app/assets/stylesheets/alchemy/upload.scss @@ -52,8 +52,8 @@ alchemy-upload-progress { display: grid; font-size: var(--font-size_medium); gap: var(--spacing-4); - grid-template-columns: calc(100% - var(--pogress_value-width)) var( - --pogress_value-width + grid-template-columns: calc(100% - var(--pogress_value-width)) calc( + var(--pogress_value-width) - var(--spacing-2) ); height: auto; left: 0; @@ -69,6 +69,12 @@ alchemy-upload-progress { opacity: 1; } + .overall-progress-value { + align-items: center; + display: flex; + justify-content: space-between; + } + .value-text { color: white; font-size: var(--font-size_large); diff --git a/app/javascript/alchemy_admin/components/uploader.js b/app/javascript/alchemy_admin/components/uploader.js index 62eaf998c3..e493b5b57d 100644 --- a/app/javascript/alchemy_admin/components/uploader.js +++ b/app/javascript/alchemy_admin/components/uploader.js @@ -81,19 +81,7 @@ export class Uploader extends AlchemyHTMLElement { return fileUpload }) - // create progress bar - this.uploadProgress = new Progress(fileUploads) - this.uploadProgress.onComplete = (status) => { - if (status === "successful") { - // wait three seconds to see the result of the progressbar - setTimeout(() => (this.uploadProgress.visible = false), 1500) - setTimeout(() => this.dispatchCustomEvent("Upload.Complete"), 2000) - } else { - this.dispatchCustomEvent("Upload.Failure") - } - } - - document.body.append(this.uploadProgress) + this._createProgress(fileUploads) } /** @@ -112,6 +100,27 @@ export class Uploader extends AlchemyHTMLElement { request.send(formData) } + /** + * create (and maybe remove the old) progress bar - component + * @param {FileUpload[]} fileUploads + * @private + */ + _createProgress(fileUploads) { + if (this.uploadProgress) { + this.uploadProgress.cancel() + document.body.removeChild(this.uploadProgress) + } + this.uploadProgress = new Progress(fileUploads) + this.uploadProgress.onComplete = (status) => { + if (status === "successful" || status === "canceled") { + this.uploadProgress.visible = false + } + this.dispatchCustomEvent(`upload.${status}`) + } + + document.body.append(this.uploadProgress) + } + /** * @returns {HTMLInputElement} */ diff --git a/app/javascript/alchemy_admin/components/uploader/file_upload.js b/app/javascript/alchemy_admin/components/uploader/file_upload.js index 6c04626cff..e4cda38f1c 100644 --- a/app/javascript/alchemy_admin/components/uploader/file_upload.js +++ b/app/javascript/alchemy_admin/components/uploader/file_upload.js @@ -51,9 +51,11 @@ export class FileUpload extends AlchemyHTMLElement { * cancel the upload */ cancel() { - this.status = "canceled" - this.request?.abort() - this.dispatchCustomEvent("FileUpload.Change") + if (!this.finished) { + this.status = "canceled" + this.request?.abort() + this.dispatchCustomEvent("FileUpload.Change") + } } /** diff --git a/app/javascript/alchemy_admin/components/uploader/progress.js b/app/javascript/alchemy_admin/components/uploader/progress.js index 6d3a0996eb..a400482f01 100644 --- a/app/javascript/alchemy_admin/components/uploader/progress.js +++ b/app/javascript/alchemy_admin/components/uploader/progress.js @@ -1,6 +1,7 @@ import { AlchemyHTMLElement } from "../alchemy_html_element" import { FileUpload } from "./file_upload" import { formatFileSize } from "../../utils/format" +import { translate } from "../../i18n" export class Progress extends AlchemyHTMLElement { #visible = false @@ -10,21 +11,77 @@ export class Progress extends AlchemyHTMLElement { */ constructor(fileUploads = []) { super() + this.buttonLabel = translate("Cancel all uploads") this.fileUploads = fileUploads this.fileCount = fileUploads.length this.className = "in-progress" this.visible = true + this.handleFileChange = () => this._updateView() } + /** + * append file progress - components for each file + */ + afterRender() { + this.actionButton = this.querySelector("button") + this.actionButton.addEventListener("click", () => { + if (this.finished) { + this.onComplete(this.status) + this.visible = false + } else { + this.cancel() + } + }) + + this.fileUploads.forEach((fileUpload) => { + this.querySelector(".single-uploads").append(fileUpload) + }) + } + + /** + * cancel requests in all remaining uploads + */ + cancel() { + this._activeUploads().forEach((upload) => { + upload.cancel() + }) + this._setupCloseButton() + } + + /** + * update view and register change event + */ connected() { this._updateView() - this.addEventListener("Alchemy.FileUpload.Change", () => this._updateView()) + this.addEventListener("Alchemy.FileUpload.Change", this.handleFileChange) + } + + /** + * deregister file upload change - event + */ + disconnected() { + this.removeEventListener("Alchemy.FileUpload.Change", this.handleFileChange) } + /** + * a complete hook to allow the uploader to react and trigger an event + * it would be possible to trigger the event here, but the dispatching would happen + * in the scope of that component and can't be cached o uploader - component level + */ + onComplete(_status) {} + render() { return ` -
+
+ + + + + +
@@ -33,51 +90,58 @@ export class Progress extends AlchemyHTMLElement { } /** - * append file progress - components for each file + * get all active upload components + * @returns {FileUpload[]} + * @private */ - afterRender() { - this.fileUploads.forEach((fileUpload) => { - this.querySelector(".single-uploads").append(fileUpload) - }) + _activeUploads() { + return this.fileUploads.filter((upload) => upload.active) } /** - * don't render the whole element new, because it would prevent selecting buttons + * replace cancel button to be the close button * @private */ - _updateView() { - const totalSize = this._activeUploads().reduce( - (accumulator, upload) => accumulator + upload.file.size, + _setupCloseButton() { + this.buttonLabel = translate("Close") + this.actionButton.ariaLabel = this.buttonLabel + this.actionButton.parentElement.content = this.buttonLabel // update tooltip content + } + + /** + * @param {string} field + * @returns {number} + * @private + */ + _sumFileProgresses(field) { + return this._activeUploads().reduce( + (accumulator, upload) => upload[field] + accumulator, 0 ) - const totalProgress = Math.ceil( - this._activeUploads().reduce((accumulator, upload) => { - const weight = upload.file.size / totalSize - return upload.value * weight + accumulator - }, 0) - ) - const uploadedFileCount = this._activeUploads().filter( - (fileProgress) => fileProgress.value >= 100 - ).length - const overallProgressValue = `${totalProgress}% (${uploadedFileCount} / ${ - this._activeUploads().length - })` - const overallUploadSize = `${formatFileSize( - this._sumFileProgresses("progressEventLoaded") - )} / ${formatFileSize(this._sumFileProgresses("progressEventTotal"))}` + } + /** + * don't render the whole element new, because it would prevent selecting buttons + * @private + */ + _updateView() { const status = this.status - this.progressElement.value = totalProgress + // update progress bar + this.progressElement.value = this.totalProgress this.progressElement.toggleAttribute( "indeterminate", status === "upload-finished" ) - this.querySelector(`.overall-progress-value`).textContent = - overallProgressValue - this.querySelector(`.overall-upload-value`).textContent = overallUploadSize + + // show progress in file size and percentage + this.querySelector(`.overall-progress-value > span`).textContent = + this.overallProgressValue + this.querySelector(`.overall-upload-value`).textContent = + this.overallUploadSize if (this.finished) { + this._setupCloseButton() this.onComplete(status) } @@ -85,13 +149,6 @@ export class Progress extends AlchemyHTMLElement { this.visible = true } - /** - * a complete hook to allow the uploader to react and trigger an event - * it would be possible to trigger the event here, but the dispatching would happen - * in the scope of that component and can't be cached o uploader - component level - */ - onComplete(_status) {} - /** * @returns {boolean} */ @@ -99,6 +156,34 @@ export class Progress extends AlchemyHTMLElement { return this._activeUploads().every((entry) => entry.finished) } + /** + * @returns {string} + */ + get overallUploadSize() { + const uploadedFileCount = this._activeUploads().filter( + (fileProgress) => fileProgress.value >= 100 + ).length + const overallProgressValue = `${ + this.totalProgress + }% (${uploadedFileCount} / ${this._activeUploads().length})` + + return `${formatFileSize( + this._sumFileProgresses("progressEventLoaded") + )} / ${formatFileSize(this._sumFileProgresses("progressEventTotal"))}` + } + + /** + * @returns {string} + */ + get overallProgressValue() { + const uploadedFileCount = this._activeUploads().filter( + (fileProgress) => fileProgress.value >= 100 + ).length + return `${this.totalProgress}% (${uploadedFileCount} / ${ + this._activeUploads().length + })` + } + /** * @returns {HTMLProgressElement|undefined} */ @@ -115,10 +200,16 @@ export class Progress extends AlchemyHTMLElement { (upload) => upload.className ) + // mark as failed, if any upload failed if (uploadsStatuses.includes("failed")) { return "failed" } + // no active upload means that every upload was canceled + if (uploadsStatuses.length === 0) { + return "canceled" + } + // all uploads are successful or upload-finished or in-progress if (uploadsStatuses.every((entry) => entry === uploadsStatuses[0])) { return uploadsStatuses[0] @@ -127,6 +218,27 @@ export class Progress extends AlchemyHTMLElement { return "in-progress" } + /** + * @returns {number} + */ + get totalProgress() { + const totalSize = this._activeUploads().reduce( + (accumulator, upload) => accumulator + upload.file.size, + 0 + ) + let totalProgress = Math.ceil( + this._activeUploads().reduce((accumulator, upload) => { + const weight = upload.file.size / totalSize + return upload.value * weight + accumulator + }, 0) + ) + // prevent rounding errors + if (totalProgress > 100) { + totalProgress = 100 + } + return totalProgress + } + /** * @returns {boolean} */ @@ -141,22 +253,6 @@ export class Progress extends AlchemyHTMLElement { this.classList.toggle("visible", visible) this.#visible = visible } - - /** - * @param {string} field - * @returns {number} - * @private - */ - _sumFileProgresses(field) { - return this._activeUploads().reduce( - (accumulator, upload) => upload[field] + accumulator, - 0 - ) - } - - _activeUploads() { - return this.fileUploads.filter((upload) => upload.active) - } } customElements.define("alchemy-upload-progress", Progress) diff --git a/app/javascript/alchemy_admin/locales/en.js b/app/javascript/alchemy_admin/locales/en.js index 36432b1008..01f1d27b66 100644 --- a/app/javascript/alchemy_admin/locales/en.js +++ b/app/javascript/alchemy_admin/locales/en.js @@ -20,6 +20,8 @@ export const en = { "Maximum number of files exceeded": "Maximum number of files exceeded", "Uploaded bytes exceed file size": "Uploaded bytes exceed file size", "Abort upload": "Abort upload", + "Cancel all uploads": "Cancel all uploads", + Close: "Close", formats: { datetime: "Y-m-d H:i", date: "Y-m-d", diff --git a/app/views/alchemy/admin/attachments/_replace_button.html.erb b/app/views/alchemy/admin/attachments/_replace_button.html.erb index 5707212465..e3ae596ff6 100644 --- a/app/views/alchemy/admin/attachments/_replace_button.html.erb +++ b/app/views/alchemy/admin/attachments/_replace_button.html.erb @@ -13,7 +13,7 @@ diff --git a/app/views/alchemy/admin/uploader/_button.html.erb b/app/views/alchemy/admin/uploader/_button.html.erb index 68b3bdc55f..47176738c8 100644 --- a/app/views/alchemy/admin/uploader/_button.html.erb +++ b/app/views/alchemy/admin/uploader/_button.html.erb @@ -17,12 +17,14 @@ diff --git a/spec/javascript/alchemy_admin/components/uploader.spec.js b/spec/javascript/alchemy_admin/components/uploader.spec.js index e068b4f247..3c28020633 100644 --- a/spec/javascript/alchemy_admin/components/uploader.spec.js +++ b/spec/javascript/alchemy_admin/components/uploader.spec.js @@ -66,6 +66,7 @@ describe("alchemy-uploader", () => { renderComponent() xhrMock = { + abort: jest.fn(), open: jest.fn(), setRequestHeader: jest.fn(), send: jest.fn(), @@ -156,6 +157,22 @@ describe("alchemy-uploader", () => { ) }) }) + + describe("another upload", () => { + it("should have only one progress - component", () => { + component._uploadFiles([firstFile]) + expect( + document.querySelectorAll("alchemy-upload-progress").length + ).toEqual(1) + }) + + it("should cancel the previous process", () => { + const uploadProgress = document.querySelector("alchemy-upload-progress") + uploadProgress.cancel = jest.fn() + component._uploadFiles([firstFile]) + expect(uploadProgress.cancel).toBeCalled() + }) + }) }) describe("Validate", () => { @@ -204,4 +221,55 @@ describe("alchemy-uploader", () => { expect(document.querySelector("alchemy-file-upload").valid).toBeFalsy() }) }) + + describe("on complete", () => { + beforeEach(() => { + component.dispatchCustomEvent = jest.fn() + component._uploadFiles([firstFile, secondFile]) + }) + + describe("successful", () => { + beforeEach(() => { + component.uploadProgress.onComplete("successful") + }) + + it("should fire upload - event", () => { + expect(component.dispatchCustomEvent).toBeCalledWith( + "upload.successful" + ) + }) + + it("should hide the progress component", () => { + expect(component.uploadProgress.visible).toBeFalsy() + }) + }) + + describe("canceled", () => { + beforeEach(() => { + component.uploadProgress.onComplete("canceled") + }) + + it("should fire upload - event", () => { + expect(component.dispatchCustomEvent).toBeCalledWith("upload.canceled") + }) + + it("should hide the progress component", () => { + expect(component.uploadProgress.visible).toBeFalsy() + }) + }) + + describe("failed", () => { + beforeEach(() => { + component.uploadProgress.onComplete("failed") + }) + + it("should fire upload - event", () => { + expect(component.dispatchCustomEvent).toBeCalledWith("upload.failed") + }) + + it("should not hide the progress component", () => { + expect(component.uploadProgress.visible).toBeTruthy() + }) + }) + }) }) diff --git a/spec/javascript/alchemy_admin/components/uploader/file_upload.spec.js b/spec/javascript/alchemy_admin/components/uploader/file_upload.spec.js index 94d30dc0f5..eafa1561d2 100644 --- a/spec/javascript/alchemy_admin/components/uploader/file_upload.spec.js +++ b/spec/javascript/alchemy_admin/components/uploader/file_upload.spec.js @@ -134,6 +134,21 @@ describe("alchemy-file-upload", () => { component.cancel() expect(component.className).toEqual("canceled") }) + + describe("finished state", () => { + beforeEach(() => { + component.status = "successful" + component.cancel() + }) + + it("should not abort request", () => { + expect(component.request.abort).not.toBeCalled() + }) + + it("should not set the status to canceled", () => { + expect(component.className).toEqual("successful") + }) + }) }) describe("request", () => { diff --git a/spec/javascript/alchemy_admin/components/uploader/progress.spec.js b/spec/javascript/alchemy_admin/components/uploader/progress.spec.js index a98052218b..4242862817 100644 --- a/spec/javascript/alchemy_admin/components/uploader/progress.spec.js +++ b/spec/javascript/alchemy_admin/components/uploader/progress.spec.js @@ -20,6 +20,7 @@ describe("alchemy-upload-progress", () => { let overallUploadValue = undefined let firstFileUpload = undefined let secondFileUpload = undefined + let actionButton = undefined const mockXMLHttpRequest = (status = 200, response = {}) => { mock.setup() @@ -49,7 +50,10 @@ describe("alchemy-upload-progress", () => { document.body.append(component) progressBar = document.querySelector("sl-progress-bar") - overallProgressValue = document.querySelector(".overall-progress-value") + overallProgressValue = document.querySelector( + ".overall-progress-value span" + ) + actionButton = document.querySelector(".icon_button") overallUploadValue = document.querySelector(".overall-upload-value") const fileUploadComponents = document.querySelectorAll( @@ -112,6 +116,12 @@ describe("alchemy-upload-progress", () => { it("should have a total progress of 0", () => { expect(progressBar.value).toEqual(0) }) + + it("shows have a cancel button", () => { + expect(actionButton.getAttribute("aria-label")).toEqual( + "Cancel all uploads" + ) + }) }) describe("update", () => { @@ -135,11 +145,6 @@ describe("alchemy-upload-progress", () => { }) describe("complete upload", () => { - // beforeEach(() => { - // firstFileUpload.request.upload.onprogress(progressEvent(100)) - // secondFileUpload.request.upload.onprogress(progressEvent(200, 200)) - // }) - it("should marked as upload-finished (the response from the server is missing)", () => { renderComponent() firstFileUpload.request.upload.onprogress(progressEvent(100)) @@ -166,6 +171,14 @@ describe("alchemy-upload-progress", () => { expect(overallProgressValue.textContent).toEqual("100% (2 / 2)") }) + it("should prevent uploads higher than 100%", () => { + renderComponent() + firstFileUpload.request.upload.onprogress(progressEvent(100)) + secondFileUpload.request.upload.onprogress(progressEvent(220, 200)) + + expect(overallProgressValue.textContent).toEqual("100% (2 / 2)") + }) + it("should marked progress as failed if one upload was not successful", async () => { const failedUpload = new FileUpload(secondFile, mockXMLHttpRequest()) failedUpload.status = "failed" @@ -228,6 +241,34 @@ describe("alchemy-upload-progress", () => { }) }) + describe("Action Button", () => { + beforeEach(renderComponent) + + it("it cancel the requests, if the upload is active", () => { + component.cancel = jest.fn() + actionButton.click() + expect(component.cancel).toBeCalled() + }) + + describe("after upload", () => { + beforeEach(() => { + firstFileUpload.status = "successful" + secondFileUpload.status = "successful" + firstFileUpload.dispatchCustomEvent("FileUpload.Change") + }) + + it("shows a close button", () => { + expect(actionButton.ariaLabel).toEqual("Close") + }) + + it("it is not visible anymore after click", () => { + expect(component.visible).toBeTruthy() + actionButton.click() + expect(component.visible).toBeFalsy() + }) + }) + }) + describe("cancel upload", () => { beforeEach(renderComponent) @@ -269,4 +310,34 @@ describe("alchemy-upload-progress", () => { expect(component.fileCount).toEqual(0) }) }) + + describe("cancel", () => { + it("should call the cancel - action file upload", () => { + const fileUpload = new FileUpload(firstFile, mockXMLHttpRequest()) + fileUpload.cancel = jest.fn() + + renderComponent([fileUpload]) + component.cancel() + expect(fileUpload.cancel).toBeCalled() + }) + + it("should have the status canceled", () => { + renderComponent() + component.cancel() + expect(component.status).toEqual("canceled") + }) + + it("should call only active file uploads", () => { + const activeFileUpload = new FileUpload(firstFile, mockXMLHttpRequest()) + const uploadedFileUpload = new FileUpload(firstFile, mockXMLHttpRequest()) + activeFileUpload.cancel = jest.fn() + uploadedFileUpload.cancel = jest.fn() + uploadedFileUpload.status = "canceled" + + renderComponent([activeFileUpload, uploadedFileUpload]) + component.cancel() + expect(activeFileUpload.cancel).toBeCalled() + expect(uploadedFileUpload.cancel).not.toBeCalled() + }) + }) })