diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdb1bc9..acb9ec3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,11 +64,6 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - - name: Install Selenium - run: | - curl -LsSfO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb - sudo dpkg -i google-chrome-stable_current_amd64.deb || sudo apt-get -f install -y - if: matrix.os == 'ubuntu-latest' - uses: astral-sh/setup-uv@v7 - run: uv run pytest -m selenium - uses: codecov/codecov-action@v5 diff --git a/s3file/forms.py b/s3file/forms.py index a9f3453..0565602 100644 --- a/s3file/forms.py +++ b/s3file/forms.py @@ -7,6 +7,7 @@ from django.templatetags.static import static from django.utils.functional import cached_property from django.utils.html import format_html, html_safe +from django.utils.safestring import mark_safe from storages.utils import safe_join from s3file.middleware import S3FileMiddleware @@ -75,7 +76,6 @@ def client(self): def build_attrs(self, *args, **kwargs): attrs = super().build_attrs(*args, **kwargs) - attrs["is"] = "s3-file" accept = attrs.get("accept") response = self.client.generate_presigned_post( @@ -97,6 +97,14 @@ def build_attrs(self, *args, **kwargs): return defaults + def render(self, name, value, attrs=None, renderer=None): + """Render the widget as a custom element for Safari compatibility.""" + return mark_safe( # noqa: S308 + str(super().render(name, value, attrs=attrs, renderer=renderer)).replace( + f' { + this._files = this._fileInput.files + this.dispatchEvent(new Event("change", { bubbles: true })) + this.changeHandler() + }) + + // Append elements + this.appendChild(this._fileInput) + + // Setup form event listeners + this.form?.addEventListener("formdata", this.fromDataHandler.bind(this)) + this.form?.addEventListener("submit", this.submitHandler.bind(this), { + once: true, + }) + this.form?.addEventListener("upload", this.uploadHandler.bind(this)) + } + + /** + * Sync attributes from custom element to hidden input. + */ + _syncAttributesToHiddenInput() { + if (!this._fileInput) return + + S3FileInput.passThroughAttributes.forEach((attr) => { + if (this.hasAttribute(attr)) { + this._fileInput.setAttribute(attr, this.getAttribute(attr)) + } else { + this._fileInput.removeAttribute(attr) + } + }) + + this._fileInput.disabled = this.hasAttribute("disabled") + } + + /** + * Implement HTMLInputElement-like properties. + */ + get files() { + return this._files + } + + get type() { + return "file" + } + + get name() { + return this.getAttribute("name") || "" + } + + set name(value) { + this.setAttribute("name", value) + } + + get value() { + if (this._files && this._files.length > 0) { + return this._files[0].name + } + return "" + } + + set value(val) { + // Setting value on file inputs is restricted for security + if (val === "" || val === null) { + this._files = [] + if (this._fileInput) { + this._fileInput.value = "" + } + } + } + + get form() { + return this._internals?.form || this.closest("form") + } + + get disabled() { + return this.hasAttribute("disabled") + } + + set disabled(value) { + if (value) { + this.setAttribute("disabled", "") + } else { + this.removeAttribute("disabled") + } + } + + get required() { + return this.hasAttribute("required") + } + + set required(value) { + if (value) { + this.setAttribute("required", "") + } else { + this.removeAttribute("required") + } + } + + get validity() { + if (this._internals) { + return this._internals.validity + } + // Create a basic ValidityState-like object + const isValid = !this.required || (this._files && this._files.length > 0) + return { + valid: isValid && !this._validationMessage, + valueMissing: this.required && (!this._files || this._files.length === 0), + customError: !!this._validationMessage, + badInput: false, + patternMismatch: false, + rangeOverflow: false, + rangeUnderflow: false, + stepMismatch: false, + tooLong: false, + tooShort: false, + typeMismatch: false, + } + } + + get validationMessage() { + return this._validationMessage + } + + setCustomValidity(message) { + this._validationMessage = message || "" + if (this._internals && typeof this._internals.setValidity === "function") { + if (message) { + this._internals.setValidity({ customError: true }, message) + } else { + this._internals.setValidity({}) + } + } + } + + reportValidity() { + const validity = this.validity + if (validity && !validity.valid) { + this.dispatchEvent(new Event("invalid", { bubbles: false, cancelable: true })) + return false + } + return true + } + + checkValidity() { + return this.validity.valid + } + + click() { + if (this._fileInput) { + this._fileInput.click() + } } changeHandler() { this.keys = [] this.upload = null try { - this.form.removeEventListener("submit", this.submitHandler.bind(this)) + this.form?.removeEventListener("submit", this.submitHandler.bind(this)) } catch (error) { console.debug(error) } - this.form.addEventListener("submit", this.submitHandler.bind(this), { once: true }) + this.form?.addEventListener("submit", this.submitHandler.bind(this), { + once: true, + }) } /** @@ -48,15 +216,15 @@ export class S3FileInput extends globalThis.HTMLInputElement { */ async submitHandler(event) { event.preventDefault() - this.form.dispatchEvent(new window.CustomEvent("upload")) - await Promise.all(this.form.pendingRquests) - this.form.requestSubmit(event.submitter) + this.form?.dispatchEvent(new window.CustomEvent("upload")) + await Promise.all(this.form?.pendingRquests) + this.form?.requestSubmit(event.submitter) } uploadHandler() { if (this.files.length && !this.upload) { this.upload = this.uploadFiles() - this.form.pendingRquests = this.form.pendingRquests || [] + this.form.pendingRquests = this.form?.pendingRquests || [] this.form.pendingRquests.push(this.upload) } } @@ -99,7 +267,10 @@ export class S3FileInput extends globalThis.HTMLInputElement { s3Form.append("file", file) console.debug("uploading", this.dataset.url, file) try { - const response = await fetch(this.dataset.url, { method: "POST", body: s3Form }) + const response = await fetch(this.dataset.url, { + method: "POST", + body: s3Form, + }) if (response.status === 201) { this.keys.push(getKeyFromResponse(await response.text())) } else { @@ -108,11 +279,29 @@ export class S3FileInput extends globalThis.HTMLInputElement { } } catch (error) { console.error(error) - this.setCustomValidity(error) + this.setCustomValidity(String(error)) this.reportValidity() } } } + + /** + * Called when observed attributes change. + */ + static get observedAttributes() { + return this.passThroughAttributes.concat(["name", "id"]) + } + + attributeChangedCallback(name, oldValue, newValue) { + this._syncAttributesToHiddenInput() + } + + /** + * Declare this element as a form-associated custom element. + */ + static get formAssociated() { + return true + } } -globalThis.customElements.define("s3-file", S3FileInput, { extends: "input" }) +globalThis.customElements.define("s3-file", S3FileInput) diff --git a/tests/__tests__/s3file.test.js b/tests/__tests__/s3file.test.js index c5582d5..d63d794 100644 --- a/tests/__tests__/s3file.test.js +++ b/tests/__tests__/s3file.test.js @@ -26,7 +26,6 @@ describe("getKeyFromResponse", () => { describe("S3FileInput", () => { test("constructor", () => { const input = new s3file.S3FileInput() - assert.strictEqual(input.type, "file") assert.deepStrictEqual(input.keys, []) assert.strictEqual(input.upload, null) }) @@ -35,11 +34,11 @@ describe("S3FileInput", () => { const form = document.createElement("form") document.body.appendChild(form) const input = new s3file.S3FileInput() - input.addEventListener = mock.fn(input.addEventListener) form.addEventListener = mock.fn(form.addEventListener) form.appendChild(input) assert(form.addEventListener.mock.calls.length === 3) - assert(input.addEventListener.mock.calls.length === 1) + assert(input._fileInput !== null) + assert(input._fileInput.type === "file") }) test("changeHandler", () => { diff --git a/tests/test_forms.py b/tests/test_forms.py index e05d586..7afca9a 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -127,7 +127,6 @@ def test_clear(self, filemodel): def test_build_attr(self, freeze_upload_folder): assert set(ClearableFileInput().build_attrs({}).keys()) == { - "is", "data-url", "data-fields-x-amz-algorithm", "data-fields-x-amz-date", @@ -141,7 +140,6 @@ def test_build_attr(self, freeze_upload_folder): ClearableFileInput().build_attrs({})["data-s3f-signature"] == "VRIPlI1LCjUh1EtplrgxQrG8gSAaIwT48mMRlwaCytI" ) - assert ClearableFileInput().build_attrs({})["is"] == "s3-file" def test_get_conditions(self, freeze_upload_folder): conditions = ClearableFileInput().get_conditions(None) @@ -182,6 +180,12 @@ def test_accept(self): "application/pdf,image/*" ) + def test_render_wraps_in_s3_file_element(self, freeze_upload_folder): + widget = ClearableFileInput() + html = widget.render(name="file", value=None) + # Check that the output is the s3-file custom element + assert html.startswith("