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()
+ })
+ })
})