Skip to content

Commit da426bb

Browse files
committed
refactor(Uploader): Extend HTMLElement directly
Dropping the AlchemyHTMLElement base class avoids the constructor-time innerHTML capture and attribute processing. The file input and dropzone listeners, plus the self-registered Alchemy.upload.successful handler, are now wired in connectedCallback. The external dropzone element's listeners are removed in disconnectedCallback, since they would otherwise hold closures that keep the Uploader alive after removal. The dropzone attribute is exposed via a plain getter. FileUpload and Progress receive the same treatment. FileUpload now sets its initial "in-progress" status through the status setter in initialize(), so a status that is set before the element is appended (e.g. status = "failed" in tests) is no longer clobbered when the element connects. Progress pulls its template out into a module-level function and uses private methods and fields for the internals that are not part of the public contract.
1 parent 0a63be1 commit da426bb

6 files changed

Lines changed: 214 additions & 196 deletions

File tree

app/assets/builds/alchemy/alchemy_admin.min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/assets/builds/alchemy/alchemy_admin.min.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/javascript/alchemy_admin/components/uploader.js

Lines changed: 71 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,75 @@
33
* @property {string} name
44
* @property {number} size
55
*/
6-
import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
76
import { Progress } from "alchemy_admin/components/uploader/progress"
87
import { FileUpload } from "alchemy_admin/components/uploader/file_upload"
98
import { translate } from "alchemy_admin/i18n"
109
import { getToken } from "alchemy_admin/utils/ajax"
1110

12-
export class Uploader extends AlchemyHTMLElement {
13-
static properties = {
14-
dropzone: { default: false }
15-
}
11+
export class Uploader extends HTMLElement {
12+
#dropzoneElement = null
13+
#isDraggedOver = false
1614

17-
connected() {
18-
this.fileInput.addEventListener("change", (event) => {
19-
this._uploadFiles(Array.from(event.target.files))
20-
})
15+
connectedCallback() {
16+
this.fileInput.addEventListener("change", this.#onFileInputChange)
2117
if (this.dropzone) {
22-
this._dragAndDropBehavior()
18+
this.#setupDropZone()
2319
}
2420
this.addEventListener("Alchemy.upload.successful", this)
2521
}
2622

23+
disconnectedCallback() {
24+
this.fileInput?.removeEventListener("change", this.#onFileInputChange)
25+
if (this.#dropzoneElement) {
26+
this.#dropzoneElement.removeEventListener(
27+
"dragleave",
28+
this.#onDropzoneDragleave
29+
)
30+
this.#dropzoneElement.removeEventListener("drop", this.#onDropzoneDrop)
31+
this.#dropzoneElement.removeEventListener(
32+
"dragover",
33+
this.#onDropzoneDragover
34+
)
35+
this.#dropzoneElement = null
36+
}
37+
}
38+
2739
handleEvent(evt) {
2840
switch (evt.type) {
2941
case "Alchemy.upload.successful":
30-
this._handleUploadComplete()
42+
this.#handleUploadComplete()
3143
break
3244
}
3345
}
3446

35-
_handleUploadComplete() {
47+
#onFileInputChange = (event) => {
48+
this.uploadFiles(Array.from(event.target.files))
49+
}
50+
51+
#toggleDropzoneClass = (enabled) => {
52+
if (this.#isDraggedOver !== enabled) {
53+
this.#isDraggedOver = enabled
54+
this.#dropzoneElement.classList.toggle("dragover")
55+
}
56+
}
57+
58+
#onDropzoneDragleave = () => this.#toggleDropzoneClass(false)
59+
60+
#onDropzoneDrop = async (event) => {
61+
event.preventDefault()
62+
this.#toggleDropzoneClass(false)
63+
64+
const files = [...event.dataTransfer.items].map((item) => item.getAsFile())
65+
66+
this.uploadFiles(files)
67+
}
68+
69+
#onDropzoneDragover = (event) => {
70+
event.preventDefault() // dragover has to be disabled to use the custom drop event
71+
this.#toggleDropzoneClass(true)
72+
}
73+
74+
#handleUploadComplete() {
3675
setTimeout(() => {
3776
const url = this.redirectUrl
3877
const turboFrame = this.closest("turbo-frame")
@@ -53,42 +92,22 @@ export class Uploader extends AlchemyHTMLElement {
5392
* add dragover class to indicate, if the file is draggable
5493
* @private
5594
*/
56-
_dragAndDropBehavior() {
57-
const dropzoneElement = document.querySelector(this.dropzone)
58-
let isDraggedOver = false
59-
60-
const toggleDropzoneClass = (enabled) => {
61-
if (isDraggedOver !== enabled) {
62-
isDraggedOver = enabled
63-
dropzoneElement.classList.toggle("dragover")
64-
}
65-
}
95+
#setupDropZone() {
96+
this.#dropzoneElement = document.querySelector(this.dropzone)
97+
if (!this.#dropzoneElement) return
6698

67-
dropzoneElement.addEventListener("dragleave", () =>
68-
toggleDropzoneClass(false)
99+
this.#dropzoneElement.addEventListener(
100+
"dragleave",
101+
this.#onDropzoneDragleave
69102
)
70-
dropzoneElement.addEventListener("drop", async (event) => {
71-
event.preventDefault()
72-
toggleDropzoneClass(false)
73-
74-
const files = [...event.dataTransfer.items].map((item) =>
75-
item.getAsFile()
76-
)
77-
78-
this._uploadFiles(files)
79-
})
80-
81-
dropzoneElement.addEventListener("dragover", (event) => {
82-
event.preventDefault() // dragover has to be disabled to use the custom drop event
83-
toggleDropzoneClass(true)
84-
})
103+
this.#dropzoneElement.addEventListener("drop", this.#onDropzoneDrop)
104+
this.#dropzoneElement.addEventListener("dragover", this.#onDropzoneDragover)
85105
}
86106

87107
/**
88108
* @param {File[]} files
89-
* @private
90109
*/
91-
_uploadFiles(files) {
110+
uploadFiles(files) {
92111
// prepare file progress bars and server request
93112
let fileUploadCount = 0
94113

@@ -102,21 +121,21 @@ export class Uploader extends AlchemyHTMLElement {
102121
fileUpload.errorMessage = translate("Maximum number of files exceeded")
103122
} else if (fileUpload.valid) {
104123
fileUploadCount++
105-
this._submitFile(request, file)
124+
this.#submitFile(request, file)
106125
}
107126

108127
return fileUpload
109128
})
110129

111-
this._createProgress(fileUploads)
130+
this.#createProgress(fileUploads)
112131
}
113132

114133
/**
115134
* @param {XMLHttpRequest} request
116135
* @param {File} file
117136
* @private
118137
*/
119-
_submitFile(request, file) {
138+
#submitFile(request, file) {
120139
const form = this.querySelector("form")
121140
const formData = new FormData(form)
122141
formData.set(this.fileInput.name, file)
@@ -132,20 +151,26 @@ export class Uploader extends AlchemyHTMLElement {
132151
* @param {FileUpload[]} fileUploads
133152
* @private
134153
*/
135-
_createProgress(fileUploads) {
154+
#createProgress(fileUploads) {
136155
if (this.uploadProgress) {
137156
this.uploadProgress.cancel()
138157
document.body.removeChild(this.uploadProgress)
139158
}
140159
this.uploadProgress = new Progress()
141160
this.uploadProgress.initialize(fileUploads)
142161
this.uploadProgress.onComplete = (status) => {
143-
this.dispatchCustomEvent(`upload.${status}`)
162+
this.dispatchEvent(
163+
new CustomEvent(`Alchemy.upload.${status}`, { bubbles: true })
164+
)
144165
}
145166

146167
document.body.append(this.uploadProgress)
147168
}
148169

170+
get dropzone() {
171+
return this.getAttribute("dropzone")
172+
}
173+
149174
/**
150175
* @returns {HTMLInputElement}
151176
*/

app/javascript/alchemy_admin/components/uploader/file_upload.js

Lines changed: 48 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,22 @@
1-
import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
21
import { formatFileSize } from "alchemy_admin/utils/format"
32
import { translate } from "alchemy_admin/i18n"
43
import { growl } from "alchemy_admin/growler"
54

6-
export class FileUpload extends AlchemyHTMLElement {
7-
constructor() {
8-
super()
9-
10-
this.file = null
11-
this.request = null
12-
13-
this.progressEventLoaded = 0
14-
this.progressEventTotal = 0
15-
this.className = "in-progress"
16-
this.valid = true
17-
this.value = 0
18-
}
19-
20-
/**
21-
* Initialize the component with file and request
22-
* @param {File} file
23-
* @param {XMLHttpRequest} request
24-
*/
25-
initialize(file, request) {
26-
this.file = file
27-
this.request = request
28-
this.progressEventTotal = file ? file.size : 0
29-
30-
this._validateFile()
31-
this._addRequestEventListener()
32-
}
33-
34-
render() {
35-
return `
5+
export class FileUpload extends HTMLElement {
6+
// public — used by callers (Uploader, Progress, tests)
7+
file = null
8+
request = null
9+
progressEventLoaded = 0
10+
progressEventTotal = 0
11+
12+
// private — backing state for getters/setters
13+
#valid = true
14+
#value = 0
15+
#status = undefined
16+
#errorMessage = ""
17+
18+
connectedCallback() {
19+
this.innerHTML = `
3620
<sl-progress-bar value="${this.value}"></sl-progress-bar>
3721
<div class="description">
3822
<span class="file-name">${this.file?.name}</span>
@@ -45,9 +29,7 @@ export class FileUpload extends AlchemyHTMLElement {
4529
</button>
4630
</sl-tooltip>
4731
`
48-
}
4932

50-
afterRender() {
5133
this.querySelector("button").addEventListener("click", () => this.cancel())
5234

5335
if (this.file?.type.includes("image")) {
@@ -61,6 +43,21 @@ export class FileUpload extends AlchemyHTMLElement {
6143
}
6244
}
6345

46+
/**
47+
* Initialize the component with file and request
48+
* @param {File} file
49+
* @param {XMLHttpRequest} request
50+
*/
51+
initialize(file, request) {
52+
this.file = file
53+
this.request = request
54+
this.progressEventTotal = file ? file.size : 0
55+
this.status = "in-progress"
56+
57+
this.#validateFile()
58+
this.#addRequestEventListener()
59+
}
60+
6461
/**
6562
* cancel the upload
6663
*/
@@ -72,11 +69,18 @@ export class FileUpload extends AlchemyHTMLElement {
7269
}
7370
}
7471

72+
/**
73+
* Dispatches a custom event with given name, namespaced under `Alchemy.`.
74+
* @param {string} name The name of the custom event
75+
*/
76+
dispatchCustomEvent(name) {
77+
this.dispatchEvent(new CustomEvent(`Alchemy.${name}`, { bubbles: true }))
78+
}
79+
7580
/**
7681
* validate given file with the `Alchemy.uploader_defaults` - configuration
77-
* @private
7882
*/
79-
_validateFile() {
83+
#validateFile() {
8084
const config = Alchemy.uploader_defaults
8185
const maxFileSize = config.file_size_limit * Math.pow(1024, 2) // in Byte
8286
let errorMessage = undefined
@@ -107,9 +111,8 @@ export class FileUpload extends AlchemyHTMLElement {
107111

108112
/**
109113
* register event listeners to react on request changes
110-
* @private
111114
*/
112-
_addRequestEventListener() {
115+
#addRequestEventListener() {
113116
// prevent errors if the component will be called without a request - object
114117
if (!this.request) {
115118
return
@@ -149,14 +152,14 @@ export class FileUpload extends AlchemyHTMLElement {
149152
* @returns {string}
150153
*/
151154
get errorMessage() {
152-
return this._errorMessage || ""
155+
return this.#errorMessage || ""
153156
}
154157

155158
/**
156159
* @param {string} message
157160
*/
158161
set errorMessage(message) {
159-
this._errorMessage = message
162+
this.#errorMessage = message
160163
const errorMessageContainer = this.querySelector(".error-message")
161164
if (errorMessageContainer) {
162165
errorMessageContainer.textContent = message
@@ -215,14 +218,14 @@ export class FileUpload extends AlchemyHTMLElement {
215218
* @returns {string}
216219
*/
217220
get status() {
218-
return this._status
221+
return this.#status
219222
}
220223

221224
/**
222225
* @param {string} status
223226
*/
224227
set status(status) {
225-
this._status = status
228+
this.#status = status
226229
this.className = status
227230

228231
this.progressElement?.toggleAttribute(
@@ -235,14 +238,14 @@ export class FileUpload extends AlchemyHTMLElement {
235238
* @returns {boolean}
236239
*/
237240
get valid() {
238-
return this._valid
241+
return this.#valid
239242
}
240243

241244
/**
242245
* @param {boolean} isValid
243246
*/
244247
set valid(isValid) {
245-
this._valid = isValid
248+
this.#valid = isValid
246249
this.classList.toggle("invalid", !isValid)
247250
}
248251

@@ -251,14 +254,14 @@ export class FileUpload extends AlchemyHTMLElement {
251254
* @returns {number}
252255
*/
253256
get value() {
254-
return this._value
257+
return this.#value
255258
}
256259

257260
/**
258261
* @param {number} value
259262
*/
260263
set value(value) {
261-
this._value = value
264+
this.#value = value
262265
if (this.progressElement) {
263266
this.progressElement.value = value
264267
}

0 commit comments

Comments
 (0)