${this.file?.name}
@@ -45,9 +29,7 @@ export class FileUpload extends AlchemyHTMLElement {
`
- }
- afterRender() {
this.querySelector("button").addEventListener("click", () => this.cancel())
if (this.file?.type.includes("image")) {
@@ -61,6 +43,21 @@ export class FileUpload extends AlchemyHTMLElement {
}
}
+ /**
+ * Initialize the component with file and request
+ * @param {File} file
+ * @param {XMLHttpRequest} request
+ */
+ initialize(file, request) {
+ this.file = file
+ this.request = request
+ this.progressEventTotal = file ? file.size : 0
+ this.status = "in-progress"
+
+ this.#validateFile()
+ this.#addRequestEventListener()
+ }
+
/**
* cancel the upload
*/
@@ -72,11 +69,18 @@ export class FileUpload extends AlchemyHTMLElement {
}
}
+ /**
+ * Dispatches a custom event with given name, namespaced under `Alchemy.`.
+ * @param {string} name The name of the custom event
+ */
+ dispatchCustomEvent(name) {
+ this.dispatchEvent(new CustomEvent(`Alchemy.${name}`, { bubbles: true }))
+ }
+
/**
* validate given file with the `Alchemy.uploader_defaults` - configuration
- * @private
*/
- _validateFile() {
+ #validateFile() {
const config = Alchemy.uploader_defaults
const maxFileSize = config.file_size_limit * Math.pow(1024, 2) // in Byte
let errorMessage = undefined
@@ -107,9 +111,8 @@ export class FileUpload extends AlchemyHTMLElement {
/**
* register event listeners to react on request changes
- * @private
*/
- _addRequestEventListener() {
+ #addRequestEventListener() {
// prevent errors if the component will be called without a request - object
if (!this.request) {
return
@@ -149,14 +152,14 @@ export class FileUpload extends AlchemyHTMLElement {
* @returns {string}
*/
get errorMessage() {
- return this._errorMessage || ""
+ return this.#errorMessage || ""
}
/**
* @param {string} message
*/
set errorMessage(message) {
- this._errorMessage = message
+ this.#errorMessage = message
const errorMessageContainer = this.querySelector(".error-message")
if (errorMessageContainer) {
errorMessageContainer.textContent = message
@@ -215,14 +218,14 @@ export class FileUpload extends AlchemyHTMLElement {
* @returns {string}
*/
get status() {
- return this._status
+ return this.#status
}
/**
* @param {string} status
*/
set status(status) {
- this._status = status
+ this.#status = status
this.className = status
this.progressElement?.toggleAttribute(
@@ -235,14 +238,14 @@ export class FileUpload extends AlchemyHTMLElement {
* @returns {boolean}
*/
get valid() {
- return this._valid
+ return this.#valid
}
/**
* @param {boolean} isValid
*/
set valid(isValid) {
- this._valid = isValid
+ this.#valid = isValid
this.classList.toggle("invalid", !isValid)
}
@@ -251,14 +254,14 @@ export class FileUpload extends AlchemyHTMLElement {
* @returns {number}
*/
get value() {
- return this._value
+ return this.#value
}
/**
* @param {number} value
*/
set value(value) {
- this._value = value
+ this.#value = value
if (this.progressElement) {
this.progressElement.value = value
}
diff --git a/app/javascript/alchemy_admin/components/uploader/progress.js b/app/javascript/alchemy_admin/components/uploader/progress.js
index ef547d6cc3..5a1dcdca25 100644
--- a/app/javascript/alchemy_admin/components/uploader/progress.js
+++ b/app/javascript/alchemy_admin/components/uploader/progress.js
@@ -1,36 +1,41 @@
-import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
import { FileUpload } from "alchemy_admin/components/uploader/file_upload"
import { formatFileSize } from "alchemy_admin/utils/format"
import { translate } from "alchemy_admin/i18n"
-export class Progress extends AlchemyHTMLElement {
+const template = (buttonLabel, fileCount) => `
+
+
+
+
+`
+
+export class Progress extends HTMLElement {
+ // public — accessed by Uploader and tests
+ fileCount = 0
+
+ // private — backing state and internals
+ #fileUploads = []
+ #buttonLabel = translate("Cancel all uploads")
+ #actionButton = null
#visible = false
+ #handleFileChange = () => this.#updateView()
- constructor() {
- super()
- this.buttonLabel = translate("Cancel all uploads")
- this.fileUploads = []
- this.fileCount = 0
- this.className = "in-progress"
+ connectedCallback() {
+ this.innerHTML = template(this.#buttonLabel, this.fileCount)
this.visible = true
- this.handleFileChange = () => this._updateView()
- }
- /**
- * Initialize the component with file uploads
- * @param {FileUpload[]} fileUploads
- */
- initialize(fileUploads = []) {
- this.fileUploads = fileUploads
- this.fileCount = fileUploads.length
- }
-
- /**
- * append file progress - components for each file
- */
- afterRender() {
- this.actionButton = this.querySelector("button")
- this.actionButton.addEventListener("click", () => {
+ this.#actionButton = this.querySelector("button")
+ this.#actionButton.addEventListener("click", () => {
if (this.finished) {
this.onComplete(this.status)
} else {
@@ -38,34 +43,38 @@ export class Progress extends AlchemyHTMLElement {
}
})
- this.fileUploads.forEach((fileUpload) => {
+ this.#fileUploads.forEach((fileUpload) => {
this.querySelector(".single-uploads").append(fileUpload)
})
+
+ this.#updateView()
+ this.addEventListener("Alchemy.FileUpload.Change", this.#handleFileChange)
}
- /**
- * cancel requests in all remaining uploads
- */
- cancel() {
- this._activeUploads().forEach((upload) => {
- upload.cancel()
- })
- this._setupCloseButton()
+ disconnectedCallback() {
+ this.removeEventListener(
+ "Alchemy.FileUpload.Change",
+ this.#handleFileChange
+ )
}
/**
- * update view and register change event
+ * Initialize the component with file uploads
+ * @param {FileUpload[]} fileUploads
*/
- connected() {
- this._updateView()
- this.addEventListener("Alchemy.FileUpload.Change", this.handleFileChange)
+ initialize(fileUploads = []) {
+ this.#fileUploads = fileUploads
+ this.fileCount = fileUploads.length
}
/**
- * deregister file upload change - event
+ * cancel requests in all remaining uploads
*/
- disconnected() {
- this.removeEventListener("Alchemy.FileUpload.Change", this.handleFileChange)
+ cancel() {
+ this.#activeUploads().forEach((upload) => {
+ upload.cancel()
+ })
+ this.#setupCloseButton()
}
/**
@@ -75,51 +84,29 @@ export class Progress extends AlchemyHTMLElement {
*/
onComplete(_status) {}
- render() {
- return `
-
-
-
-
- `
- }
-
/**
* get all active upload components
* @returns {FileUpload[]}
- * @private
*/
- _activeUploads() {
- return this.fileUploads.filter((upload) => upload.active)
+ #activeUploads() {
+ return this.#fileUploads.filter((upload) => upload.active)
}
/**
* replace cancel button to be the close button
- * @private
*/
- _setupCloseButton() {
- this.buttonLabel = translate("Close")
- this.actionButton.ariaLabel = this.buttonLabel
- this.actionButton.parentElement.content = this.buttonLabel // update tooltip content
+ #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(
+ #sumFileProgresses(field) {
+ return this.#activeUploads().reduce(
(accumulator, upload) => upload[field] + accumulator,
0
)
@@ -127,9 +114,8 @@ export class Progress extends AlchemyHTMLElement {
/**
* don't render the whole element new, because it would prevent selecting buttons
- * @private
*/
- _updateView() {
+ #updateView() {
const status = this.status
this.className = status
@@ -147,7 +133,7 @@ export class Progress extends AlchemyHTMLElement {
this.overallUploadSize
if (this.finished) {
- this._setupCloseButton()
+ this.#setupCloseButton()
this.onComplete(status)
} else {
this.visible = true
@@ -158,34 +144,34 @@ export class Progress extends AlchemyHTMLElement {
* @returns {boolean}
*/
get finished() {
- return this._activeUploads().every((entry) => entry.finished)
+ return this.#activeUploads().every((entry) => entry.finished)
}
/**
* @returns {string}
*/
get overallUploadSize() {
- const uploadedFileCount = this._activeUploads().filter(
+ const uploadedFileCount = this.#activeUploads().filter(
(fileProgress) => fileProgress.value >= 100
).length
const overallProgressValue = `${
this.totalProgress
- }% (${uploadedFileCount} / ${this._activeUploads().length})`
+ }% (${uploadedFileCount} / ${this.#activeUploads().length})`
return `${formatFileSize(
- this._sumFileProgresses("progressEventLoaded")
- )} / ${formatFileSize(this._sumFileProgresses("progressEventTotal"))}`
+ this.#sumFileProgresses("progressEventLoaded")
+ )} / ${formatFileSize(this.#sumFileProgresses("progressEventTotal"))}`
}
/**
* @returns {string}
*/
get overallProgressValue() {
- const uploadedFileCount = this._activeUploads().filter(
+ const uploadedFileCount = this.#activeUploads().filter(
(fileProgress) => fileProgress.value >= 100
).length
return `${this.totalProgress}% (${uploadedFileCount} / ${
- this._activeUploads().length
+ this.#activeUploads().length
})`
}
@@ -201,7 +187,7 @@ export class Progress extends AlchemyHTMLElement {
* @returns {string}
*/
get status() {
- const uploadsStatuses = this._activeUploads().map(
+ const uploadsStatuses = this.#activeUploads().map(
(upload) => upload.className
)
@@ -227,12 +213,12 @@ export class Progress extends AlchemyHTMLElement {
* @returns {number}
*/
get totalProgress() {
- const totalSize = this._activeUploads().reduce(
+ const totalSize = this.#activeUploads().reduce(
(accumulator, upload) => accumulator + upload.file.size,
0
)
let totalProgress = Math.ceil(
- this._activeUploads().reduce((accumulator, upload) => {
+ this.#activeUploads().reduce((accumulator, upload) => {
const weight = upload.file.size / totalSize
return upload.value * weight + accumulator
}, 0)
diff --git a/spec/javascript/alchemy_admin/components/alchemy_html_element.spec.js b/spec/javascript/alchemy_admin/components/alchemy_html_element.spec.js
deleted file mode 100644
index 21c197c016..0000000000
--- a/spec/javascript/alchemy_admin/components/alchemy_html_element.spec.js
+++ /dev/null
@@ -1,175 +0,0 @@
-import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
-import { renderComponent } from "./component.helper"
-
-describe("AlchemyHTMLElement", () => {
- let component = undefined
-
- const componentName = () =>
- `test-element-${Math.ceil(Math.random() * 10000000000000)}`
-
- describe("Render", () => {
- /**
- * create a new web component
- * you can't recreate (or remove) a web component. So it has to be a new one with another name
- * @param {string} content
- * @param {string} initialContent
- */
- const createComponent = (content = "", initialContent = "") => {
- const name = componentName()
-
- customElements.define(
- name,
- class Test extends AlchemyHTMLElement {
- render() {
- return content
- }
- }
- )
- component = renderComponent(name, `<${name}>${initialContent}${name}>`)
- }
-
- it("should render only the component", () => {
- createComponent()
- expect(component).toBeInstanceOf(HTMLElement)
- expect(component.innerHTML).toEqual("")
- })
-
- it("should render the content of the given render function", () => {
- createComponent("Foo")
- expect(component.innerHTML).toEqual("Foo")
- })
-
- it("should store the initial content", () => {
- createComponent("", "Bar")
- expect(component.initialContent).toEqual("Bar")
- })
-
- it("should render the initial content if no render function is given", () => {
- customElements.define(
- "test-initial-content",
- class Test extends AlchemyHTMLElement {}
- )
- component = renderComponent(
- "test-initial-content",
- `
FooBar`
- )
- expect(component.innerHTML).toEqual("FooBar")
- })
- })
-
- describe("Attributes", () => {
- /**
- * @param {string} name
- */
- const createComponent = (name = componentName()) => {
- customElements.define(
- name,
- class Test extends AlchemyHTMLElement {
- static properties = {
- size: { default: "medium" },
- color: { default: "currentColor" },
- longLongAttribute: { default: "foo" },
- booleanType: { default: false }
- }
- }
- )
- component = renderComponent(name, `<${name}>${name}>`)
- }
-
- it("should configure attributes and set default values", () => {
- createComponent()
- expect(component.size).toEqual("medium")
- expect(component.color).toEqual("currentColor")
- })
-
- it("should be able to set attributes", () => {
- createComponent("test-size")
- component = renderComponent(
- "test-size",
- `
`
- )
- expect(component.size).toEqual("large")
- })
-
- it("should cast dashes to camelcase", () => {
- createComponent("test-camelcase")
- component = renderComponent(
- "test-camelcase",
- `
`
- )
- expect(component.longLongAttribute).toEqual("bar")
- })
-
- it("should support boolean types", () => {
- createComponent("test-boolean")
- component = renderComponent(
- "test-boolean",
- `
`
- )
- expect(component.booleanType).toBeTruthy()
-
- const second_component = renderComponent(
- "test-boolean",
- `
`
- )
- expect(second_component.booleanType).toBeFalsy()
- })
-
- it("should observe an attribute change", () => {
- createComponent("test-color")
- expect(component.color).toEqual("currentColor")
- component.setAttribute("color", "pink")
- expect(component.color).toEqual("pink")
- })
-
- it("should rerender after a attribute change", () => {
- customElements.define(
- "test-attribute-change",
- class Test extends AlchemyHTMLElement {
- static properties = {
- foo: { default: "bar" }
- }
-
- render() {
- return `Test: ${this.foo}`
- }
- }
- )
- component = renderComponent(
- "test-attribute-change",
- "
"
- )
- expect(component.innerHTML).toEqual("Test: foo")
- component.setAttribute("foo", "fooBar")
- expect(component.innerHTML).toEqual("Test: fooBar")
- })
- })
-
- describe("Options", () => {
- class Test extends AlchemyHTMLElement {
- static properties = {
- test: { default: "foo" }
- }
- }
-
- beforeAll(() => {
- customElements.define("test-options", Test)
- component = renderComponent(
- "test-options",
- "
"
- )
- })
-
- it("should have options", () => {
- const newComponent = new Test({ test: "bar" })
- expect(newComponent.options).toEqual({ test: "bar" })
- })
-
- it("should use the given option as default property", () => {
- const newComponent = new Test({ test: "bar" })
- document.body.append(newComponent) // the new component should be append to a DOM node to run as a Web Component
- expect(component.test).toEqual("foo")
- expect(newComponent.test).toEqual("bar")
- })
- })
-})
diff --git a/spec/javascript/alchemy_admin/components/uploader.spec.js b/spec/javascript/alchemy_admin/components/uploader.spec.js
index 63ada6a123..739a1cc7f0 100644
--- a/spec/javascript/alchemy_admin/components/uploader.spec.js
+++ b/spec/javascript/alchemy_admin/components/uploader.spec.js
@@ -79,20 +79,20 @@ describe("alchemy-uploader", () => {
describe("input field", () => {
it("should call the upload function if the file input changes", () => {
- component._uploadFiles = vi.fn()
+ component.uploadFiles = vi.fn()
input.dispatchEvent(new CustomEvent("change"))
- expect(component._uploadFiles).toHaveBeenCalledTimes(1)
+ expect(component.uploadFiles).toHaveBeenCalledTimes(1)
})
})
- describe("_uploadFiles", () => {
+ describe("uploadFiles", () => {
it("should upload files", () => {
- component._uploadFiles([firstFile, secondFile])
+ component.uploadFiles([firstFile, secondFile])
expect(XMLHttpRequest).toHaveBeenCalledTimes(2)
})
it("should open the correct url", () => {
- component._uploadFiles([firstFile])
+ component.uploadFiles([firstFile])
const mockInstance = XMLHttpRequest.mock.results[0].value
expect(mockInstance.open).toHaveBeenCalledWith(
"POST",
@@ -101,7 +101,7 @@ describe("alchemy-uploader", () => {
})
it("should send the file form", () => {
- component._uploadFiles([firstFile])
+ component.uploadFiles([firstFile])
const mockInstance = XMLHttpRequest.mock.results[0].value
expect(mockInstance.send).toHaveBeenCalledWith(new FormData(form))
})
@@ -111,7 +111,7 @@ describe("alchemy-uploader", () => {
let progressBar = undefined
beforeEach(() => {
- component._uploadFiles([firstFile])
+ component.uploadFiles([firstFile])
progressBar = document.querySelector(
"alchemy-upload-progress sl-progress-bar"
)
@@ -160,7 +160,7 @@ describe("alchemy-uploader", () => {
describe("another upload", () => {
it("should have only one progress - component", () => {
- component._uploadFiles([firstFile])
+ component.uploadFiles([firstFile])
expect(
document.querySelectorAll("alchemy-upload-progress").length
).toEqual(1)
@@ -169,7 +169,7 @@ describe("alchemy-uploader", () => {
it("should cancel the previous process", () => {
const uploadProgress = document.querySelector("alchemy-upload-progress")
uploadProgress.cancel = vi.fn()
- component._uploadFiles([firstFile])
+ component.uploadFiles([firstFile])
expect(uploadProgress.cancel).toBeCalled()
})
})
@@ -180,7 +180,7 @@ describe("alchemy-uploader", () => {
beforeEach(() => {
vi.clearAllMocks() // Clear mocks before this specific test
Alchemy.uploader_defaults.upload_limit = 2
- component._uploadFiles([firstFile, secondFile, new File([], "foo")])
+ component.uploadFiles([firstFile, secondFile, new File([], "foo")])
})
it("should upload only two files", () => {
@@ -217,7 +217,7 @@ describe("alchemy-uploader", () => {
beforeEach(() => {
vi.clearAllMocks() // Clear mocks before this specific test
Alchemy.uploader_defaults.allowed_filetypes.alchemy_attachments = ["txt"]
- component._uploadFiles([
+ component.uploadFiles([
new File([], "foo.pdf", { type: "application/pdf" }),
firstFile,
secondFile
@@ -244,8 +244,8 @@ describe("alchemy-uploader", () => {
describe("on complete", () => {
beforeEach(() => {
- component.dispatchCustomEvent = vi.fn()
- component._uploadFiles([firstFile, secondFile])
+ component.dispatchEvent = vi.fn()
+ component.uploadFiles([firstFile, secondFile])
})
describe("successful", () => {
@@ -254,8 +254,8 @@ describe("alchemy-uploader", () => {
})
it("should fire upload - event", () => {
- expect(component.dispatchCustomEvent).toBeCalledWith(
- "upload.successful"
+ expect(component.dispatchEvent).toBeCalledWith(
+ expect.objectContaining({ type: "Alchemy.upload.successful" })
)
})
})
@@ -266,7 +266,9 @@ describe("alchemy-uploader", () => {
})
it("should fire upload - event", () => {
- expect(component.dispatchCustomEvent).toBeCalledWith("upload.canceled")
+ expect(component.dispatchEvent).toBeCalledWith(
+ expect.objectContaining({ type: "Alchemy.upload.canceled" })
+ )
})
})
@@ -276,7 +278,9 @@ describe("alchemy-uploader", () => {
})
it("should fire upload - event", () => {
- expect(component.dispatchCustomEvent).toBeCalledWith("upload.failed")
+ expect(component.dispatchEvent).toBeCalledWith(
+ expect.objectContaining({ type: "Alchemy.upload.failed" })
+ )
})
it("should not hide the progress component", () => {
@@ -285,10 +289,10 @@ describe("alchemy-uploader", () => {
})
})
- describe("_handleUploadComplete", () => {
+ describe("uploadFiles", () => {
beforeEach(() => {
vi.useFakeTimers()
- component._uploadFiles([firstFile])
+ component.uploadFiles([firstFile])
})
afterEach(() => {