Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion s3file/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,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(
Expand All @@ -97,6 +96,53 @@ 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."""
# Build attributes for the render - this includes data-* attributes
if attrs is None:
attrs = {}

# Ensure name is in attrs
attrs = attrs.copy() if attrs else {}
attrs['name'] = name

# Get all the attributes including data-* attributes from build_attrs
final_attrs = self.build_attrs(self.attrs, attrs)

# Build attributes string for the s3-file element
from django.utils.html import format_html_join
from django.utils.safestring import mark_safe
attrs_html = format_html_join(
' ',
'{}="{}"',
final_attrs.items()
)

# Render the s3-file custom element
# For ClearableFileInput, we also need to show the current value and clear checkbox
output = []
if value and hasattr(value, 'url'):
# Show currently uploaded file (similar to ClearableFileInput)
output.append(format_html(
'<div>Currently: <a href="{}">{}</a></div>',
value.url,
value
))
# Add clear checkbox
clear_checkbox_name = self.clear_checkbox_name(name)
clear_checkbox_id = self.clear_checkbox_id(clear_checkbox_name)
output.append(format_html(
'<div><input type="checkbox" name="{}" id="{}"><label for="{}"> Clear</label></div>',
clear_checkbox_name,
clear_checkbox_id,
clear_checkbox_id
))

# Add the s3-file custom element
output.append(format_html('<s3-file {}></s3-file>', attrs_html))

return mark_safe(''.join(str(part) for part in output))

def get_conditions(self, accept):
conditions = [
{"bucket": self.bucket_name},
Expand Down
270 changes: 259 additions & 11 deletions s3file/static/s3file/js/s3file.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,263 @@ export function getKeyFromResponse (responseText) {

/**
* Custom element to upload files to AWS S3.
* Safari-compatible autonomous custom element that acts as a file input.
*
* @extends HTMLInputElement
* @extends HTMLElement
*/
export class S3FileInput extends globalThis.HTMLInputElement {
export class S3FileInput extends globalThis.HTMLElement {
constructor () {
super()
this.type = 'file'
this.keys = []
this.upload = null
this._files = []
this._validationMessage = ''
this._internals = null

// Try to attach ElementInternals for form participation
try {
this._internals = this.attachInternals?.()
} catch (e) {
// ElementInternals not supported
}
}

connectedCallback () {
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))
this.addEventListener('change', this.changeHandler.bind(this))
// Create a hidden file input for the file picker functionality
this._hiddenInput = document.createElement('input')
this._hiddenInput.type = 'file'
this._hiddenInput.style.display = 'none'

// Sync attributes to hidden input
this._syncAttributesToHiddenInput()

// Listen for file selection on hidden input
this._hiddenInput.addEventListener('change', () => {
this._files = this._hiddenInput.files
this.dispatchEvent(new Event('change', { bubbles: true }))
this.changeHandler()
})

// Create visible button for file selection
this._createButton()

// Append elements
this.appendChild(this._hiddenInput)

// 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))
}

/**
* Create the visible button for file selection.
*/
_createButton () {
this._button = document.createElement('button')
this._button.type = 'button'
this._button.textContent = 'Choose File'
this._button.style.cssText = `
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 14px;
`

this._button.addEventListener('click', () => {
if (!this.disabled) {
this._hiddenInput.click()
}
})

this.appendChild(this._button)

// Add file name display
this._fileNameDisplay = document.createElement('span')
this._fileNameDisplay.style.cssText = 'margin-left: 8px; color: #666;'
this.appendChild(this._fileNameDisplay)

this._updateDisplay()
}

/**
* Update the display of selected files.
*/
_updateDisplay () {
if (!this._fileNameDisplay) return

if (this._files && this._files.length > 0) {
const names = Array.from(this._files).map(f => f.name).join(', ')
this._fileNameDisplay.textContent = names
} else {
this._fileNameDisplay.textContent = 'No file chosen'
}
}

/**
* Sync attributes from custom element to hidden input.
*/
_syncAttributesToHiddenInput () {
if (!this._hiddenInput) return

const attrsToSync = ['accept', 'required', 'multiple']
attrsToSync.forEach(attr => {
if (this.hasAttribute(attr)) {
this._hiddenInput.setAttribute(attr, this.getAttribute(attr))
} else {
this._hiddenInput.removeAttribute(attr)
}
})

if (this.hasAttribute('disabled')) {
this._hiddenInput.disabled = true
if (this._button) {
this._button.disabled = true
this._button.style.cursor = 'not-allowed'
this._button.style.opacity = '0.6'
}
} else {
this._hiddenInput.disabled = false
if (this._button) {
this._button.disabled = false
this._button.style.cursor = 'pointer'
this._button.style.opacity = '1'
}
}
}

/**
* 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._hiddenInput) {
this._hiddenInput.value = ''
}
this._updateDisplay()
}
}

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._hiddenInput) {
this._hiddenInput.click()
}
}

changeHandler () {
this.keys = []
this.upload = null
this._updateDisplay()
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 })
}

/**
Expand Down Expand Up @@ -108,11 +338,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 ['name', 'accept', 'required', 'multiple', 'disabled', '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)
5 changes: 2 additions & 3 deletions tests/__tests__/s3file.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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)
})
Expand All @@ -33,11 +32,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._hiddenInput !== null)
assert(input._hiddenInput.type === 'file')
})

test('changeHandler', () => {
Expand Down
Loading