This element implements HTML form functionality and also adds features that help with common tasks in modern web aplications.
While it supports TPE components and native form elements, native elements don’t get a reference to the en-form element in their ‘form’ property.
import { LitElement, html } from 'lit'
import { StyleableMixin } from '../mixins/StyleableMixin.js'
import { formElement } from '../lib/htmlApi.js'
import tpeRegistry from '../tpeRegistry'
/* globals customElements CustomEvent */
export class EnForm extends StyleableMixin(LitElement) {
get reflectProperties () {
return [...super.reflectProperties, ...formElement]
}
get skipProperties () {
return [...super.skipProperties, 'elements', 'checkValidity', 'reportValidity', 'reset', 'submit']
}
static get properties () {
return {
fetchingElement: {
type: String,
attribute: 'fetching-element'
},
recordId: {
type: String,
attribute: 'record-id'
},
setFormAfterSubmit: {
type: Boolean,
attribute: 'set-form-after-submit'
},
resetFormAfterSubmit: {
type: Boolean,
attribute: 'reset-form-after-submit'
},
validateOnLoad: {
type: Boolean,
attribute: 'validate-on-load'
},
validateOnRender: {
type: Boolean,
attribute: 'validate-on-render'
},
submitCheckboxesAsNative: {
type: Boolean,
attribute: 'submit-checkboxes-as-native'
},
noAutoload: {
type: Boolean,
attribute: 'no-autoload'
},This will allow users to redefine methods declaratively
presubmit: { type: Object, attribute: false },
response: { type: Object, attribute: false },
incomingData: { type: Object, attribute: false },
dataLoaded: { type: Object, attribute: false },
extrapolateErrors: { type: Object, attribute: false }
}
}
constructor () {
super()
this.validateOnLoad = false
this.validateOnRender = false
this.fetchingElement = null
this.submitCheckboxesAsNative = false
this._boundRealtimeSubmitter = this._realTimeSubmitter.bind(this)
this.attemptedFlight = false
this.inFlightMap = new WeakMap()
this.attemptedFlightMap = new WeakMap()
this.submitObject = {}Set WAI-ARIA Role attribute as form
this.setAttribute('role', 'form')
}
/**
* These methods were originally part of nn-form
*/
reportValidity () {Check validity in form
let valid = true
for (const el of this.elements) {
if (typeof el.reportValidity === 'function') {Native element may have customValidity set by a server response. Clear any existing custom errors and report validity
el.setCustomValidity('')
if (!el.reportValidity()) {
valid = false
}
}
}
return valid
}
clearAllCustomValidity (elements) {
for (const el of elements) {
if (typeof el.setCustomValidity === 'function') el.setCustomValidity('')
}
}
checkValidity () {Check validity in form
let valid = true
for (const el of this.elements) {
if (typeof el.checkValidity === 'function') {Native element may have customValidity set by a server response. Clear any existing custom errors and report validity
el.setCustomValidity('')
if (!el.checkValidity()) {
valid = false
}
}
}
return valid
}
reset () {TODO: Adjust this for radios in a nice sensible way
for (const el of this.elements) {
const valueSource = this._getElementValueSource(el)Reset validity, so that error messages are also reset
if (typeof el.setCustomValidity === 'function') el.setCustomValidity('')
if (this._radioElement(el)) {
el[valueSource] = el.getAttribute(valueSource) !== null
} else if (this._checkboxElement(el)) {
el[valueSource] = el.hasAttribute(valueSource)
} else {
el[valueSource] = el.getAttribute(valueSource)
}
}
}
createSubmitObject (elements) {
const r = {}
for (const el of elements) {
const elName = el.getAttribute('name')Every submit element MUST have a name set
if (typeof elName === 'undefined' || elName === null) continueRadio will only happen once thanks to checking for undefined
if (typeof r[elName] !== 'undefined') continue
if (el.hasAttribute('no-submit')) continueCheckboxes are special: they might be handled as native ones, (NOTHING set if unchecked, and their value set if checked) or as booleans (true for checked, or false for unchecked)
if (this._checkboxElement(el)) {
if (this.submitCheckboxesAsNative) {As native checkboxes.
const val = this.getFormElementValue(elName)
if (val) r[elName] = val
} else {As more app-friendly boolean value
r[elName] = !!this.getFormElementValue(elName)
}For “file” types (uploads), it will
} else if (el.getAttribute('type') === 'file' || el.getAttribute('as-file') !== null) {
r[elName] = el
} else {
r[elName] = this.getFormElementValue(elName)
}
}
return r
}
getFormElementValue (elName) {
const elements = [...this.elements].filter(el => el.getAttribute('name') === elName)
if (!elements.length) {
console.error('Trying to set', elName, 'but no such element in form')
return
}
if (elements.length === 1) {
const el = elements[0]
const valueSource = this._getElementValueSource(el)
if (this._checkboxElement(el)) {
return el[valueSource]
? (el.value ? el.value : 'on')
: undefined
} else if (this._selectElement(el)) {
return el[valueSource]
} else {
return el[valueSource]
}
} else {
const nonRadio = elements.filter(el => !this._radioElement(el))
if (nonRadio.length) {
console.error('Duplicate name', elName, 'for non-radio elements')
return
}
const checked = elements.find(el => {
const valueSource = this._getElementValueSource(el)
return el[valueSource]
})
if (checked) return checked.value
else return undefined
}
}
setFormElementValue (elName, value, skipHiddenElements) {
const el = [...this.elements].find(el => {
if (this._radioElement(el)) {
return el.getAttribute('name') === elName && el.value === value
} else {
return el.getAttribute('name') === elName
}
})Don’t do anything if the element wasn’t found OR if the type was hidden (which 99.9% of the time is set by the form)
if ((!el || (el.getAttribute('type') === 'hidden')) && skipHiddenElements) returnGet the original value
const valueSource = this._getElementValueSource(el)CHECKBOXES
if (this._checkboxElement(el)) {
el[valueSource] = !!valueRADIO Radio elements
} else if (this._radioElement(el)) {
el[valueSource] = true
const others = [...this.elements].filter(e =>
el !== e &&
this._radioElement(el)
)
for (const other of others) other[valueSource] = falseSELECT Selectable elements (with prop selectedIndex)
} else if (this._selectElement(el)) {
if (!value) el.selectedIndex = 0
else el[valueSource] = valueFILE INPUT
} else if (el.getAttribute('type') === 'file' || el.getAttribute('as-file') !== null) {
el.fileName = valueAny other case
} else {
el[valueSource] = value
}
}
_selectElement (el) {
if (typeof el.selectedIndex !== 'undefined' || el.hasAttribute('as-select')) return true
return false
}
_checkboxElement (el) {
if (el.getAttribute('type') === 'checkbox') return true
if (el.hasAttribute('as-checkbox')) return true
return false
}
_radioElement (el) {
if (el.getAttribute('type') === 'radio') return true
if (el.hasAttribute('as-radio')) return true
return false
}
_getElementValueSource (el) {
if (
el.getAttribute('type') === 'checkbox' ||
el.getAttribute('type') === 'radio' ||
el.hasAttribute('as-checkbox') ||
el.hasAttribute('as-radio')
) return 'checked'
if (el.getAttribute('value-source')) return el.getAttribute('value-source')
return 'value'
}
get elements () {A tags (links) can have “name”, filter them out
return [...this.querySelectorAll('[name]')].filter(el => el.tagName !== 'A')
}
/**
* These methods were originally part of nn-form
* (END)
*/
async _allChildrenCompleted () {Wait for all children to be ready to rock and roll
for (const el of this.elements) {TODO: What about React, Vue, etc.? Uniform API across element libraries?
if (typeof el.updateComplete !== 'undefined') {
await el.updateComplete
}
}
}
_realTimeSubmitter (e) {
this.submit(e.target)
}
connectedCallback () {
super.connectedCallback()
this._allChildrenCompleted().then(() => {
for (const el of this.elements) {
const realTime = el.getAttribute('real-time') !== null
const realTimeEvent = el.getAttribute('real-time-event') || 'input'
if (!realTime || !realTimeEvent) continue
el.addEventListener(realTimeEvent, this._boundRealtimeSubmitter)
}
})
}
disconnectedCallback () {
super.disconnectedCallback()
for (const el of this.elements) {
const realTime = el.getAttribute('real-time')
if (realTime === null) continue
const realTimeEvent = el.getAttribute('real-time-event')
if (!realTimeEvent) continue
el.removeEventListener(realTimeEvent, this._boundRealtimeSubmitter)
}
}
async firstUpdated () {
super.firstUpdated()
if (this.validateOnRender) {
await this._allChildrenCompleted()Check validity
this.reportValidity()
}
}
setFormElementValues (o) {
for (const k in o) {
this.setFormElementValue(k, o[k], true)
}
}
setRecordObject (o) {
o = { ...o }
const elHash = {}
for (const el of this.elements) elHash[el.getAttribute('name')] = el
for (const k of Object.keys(elHash)) {
o[k] = this.getFormElementValue(k)
}
return o
}
extrapolateErrors (o) {
return o
}
async presubmit (fetchOptions) {}
async response (response, errs, fetchOptions) {} // If (response !== null and response.ok), it worked
async incomingData (o, op) {} // op can be 'autoload' or 'submit'
async dataLoaded (o, op) {} // op can be 'autoload' or 'submit'Disabled here is set (and checked) with both the attribute and the property ‘disabled’ since an element might be disabled in the html, but might not have had a chance to render yet (in which case, for non-native elemtns, it would mean that the property is not yet there, since the reflector hasn’t yet run)
_disableElements (elements) {
this.__disabled = new WeakMap()
for (const el of elements) {
if (!el.disabled && !el.hasAttribute('disabled')) {
el.setAttribute('disabled', true)
el.disabled = true
this.__disabled.set(el, true)
}
}
}
_enableElements (elements) {
this.__disabled = this.__disabled || new WeakMap()
for (const el of elements) {
if (this.__disabled.has(el)) {
el.removeAttribute('disabled')
el.disabled = false
this.__disabled.delete(el)
}
}
}
_fetchEl (specificElement) {Tries to figure out what the fetching element is.
if fetching-element was passed, then it’s either considered an ID
or the element itself.
Otherwise it will look for an ee-network or with an element with class
.network. Finally, it will use window
if (specificElement) {
let pEl
pEl = specificElement
let found = false
while (pEl.parentElement) {
pEl = pEl.parentElement
if (pEl.tagName === 'EE-NETWORK' || pEl.classList.contains('network')) {
found = true
break
}
}
return found ? pEl : window
} else {
if (this.fetchingElement) {
if (typeof this.fetchingElement === 'string') return this.querySelector(`#${this.fetchingElement}`)
else return this.fetchingElement
} else {
let maybeNetwork = this.querySelector('ee-network')
if (!maybeNetwork) maybeNetwork = this.querySelector('.network')
return maybeNetwork || window
}
}
}
async _wait (ms) {
return new Promise(resolve => {
setTimeout(resolve, ms)
})
}
async submit (specificElement) {Clear all custom validities if they are set Native elements will NEED this, or any invalid state will persist even if validation passes Then, make up submit object and check whether reportValidity returns false (which basically means “abort”)
if (specificElement) {
this.clearAllCustomValidity([specificElement])
this.submitObject = this.createSubmitObject([specificElement])
if (typeof specificElement.reportValidity === 'function' && !specificElement.reportValidity()) return
} else {
this.clearAllCustomValidity(this.elements)
this.submitObject = this.createSubmitObject(this.elements)
if (!this.reportValidity()) return
}Give users the ability to listen to @submit and then Allow for a presubmit hook
const submitEvent = new CustomEvent('submit', { cancelable: true, bubbles: true, composed: true })
this.dispatchEvent(submitEvent)
if (submitEvent.defaultPrevented) returninFlightMap is a map of all connections, using the specificElement as key (or “window” if there is no specific element)
const mapIndex = specificElement || thisThe connection is ongoing: add a “specificElement” to the attempted field, and simply return. Towards the end, this function will check that “attempted” which, if set, means that the form needs to be resubmitted with that specificElement
if (this.inFlightMap.has(mapIndex)) {
this.inFlightMap.set(mapIndex, { attempted: true })
return
}
this.inFlightMap.set(mapIndex, { attempted: false })The element’s method can only be null, POST or PUT. If not null, and not “PUT”, it’s set to “POST”
let elementMethod = this.getAttribute('method')
if (elementMethod && elementMethod.toUpperCase() !== 'PUT') {
elementMethod = 'POST'
}The ‘method’ attribute will have priority no matter what.
If method is not set, then it will depend on recordId (PUT if present,
POST if not)
const method = elementMethod === null
? this.recordId ? 'PUT' : 'POST'
: elementMethodSet the url, which will also depend on recordId
const action = this.getAttribute('action')
if (!action) throw new Error('The submitted form has no action URL set')
const url = action + (this.recordId ? `/${this.recordId}` : '')
const fetchOptions = {
url,
method,
headers: { 'Content-Type': this.getAttribute('enctype') || 'application/json' },
redirect: 'follow', // manual, *follow, error
body: this.submitObject // body data type must match "Content-Type" header
}HOOK: Allow devs to customise the request about to be sent to the server
await this.presubmit(fetchOptions)Disable the elements
if (!specificElement) this._disableElements(this.elements)Delete the multipart/form-data header if it was set, since the browser will set it (with the right boundary parameter) https://muffinman.io/uploading-files-using-fetch-multipart-form-data/ https://stackoverflow.com/questions/35192841/fetch-post-with-multipart-form-data
ALSO turn body into a FormData object, with all values appended. Note that for files. createSubmitObject will assign the element itself as the value.
if (fetchOptions.headers['Content-Type'] === 'multipart/form-data') {
delete fetchOptions.headers['Content-Type']
const body = fetchOptions.body
const formData = new FormData()
for (const k in body) {
if (body[k] instanceof HTMLElement) {
const filesInEl = body[k].files
for (const f of filesInEl) formData.append(k, f)
} else {
if (typeof body[k] === 'undefined' || body[k] === null) formData.append(k, '')
else formData.append(k, body[k])
}
}
fetchOptions.body = formData
}Attempt the submission
let networkError = false
let response
let errs
const body =
fetchOptions.headers['Content-Type'] === 'application/json' &&
typeof fetchOptions.body === 'object' &&
fetchOptions.body !== null
? JSON.stringify(fetchOptions.body)
: fetchOptions.body
try {fetch() wants a stingified body
const fo = { ...fetchOptions, ...{ body: body } }
const el = this._fetchEl(specificElement)
response = await el.fetch(fetchOptions.url, fo)
} catch (e) {
console.log('ERROR!', e)
networkError = true
}CASE #1: network error.
if (networkError) {
console.log('Network error!')Re-enable the elements
if (!specificElement) {
this._enableElements(this.elements)
await this._wait(0)
}Emit event to make it possible to tell the user via UI about the problem
const event = new CustomEvent('form-error', { detail: { type: 'network' }, bubbles: true, composed: true })
this.dispatchEvent(event)Response hook
await this.response(null, null, fetchOptions)CASE #2: HTTP error. Invalidate the problem fields
} else if (!response.ok) {Try and get the errors object from the reponse’s json
let originalErrs
try {
originalErrs = await response.json()
} catch (e) {
originalErrs = {}
}
errs = this.extrapolateErrors(originalErrs) || {}Emit event to make it possible to tell the user via UI about the problem
const event = new CustomEvent('form-error', { detail: { type: 'http', response, errs }, bubbles: true, composed: true })
this.dispatchEvent(event)Re-enable the elements This must happen before setCustomValidity() and reportValidity()
if (!specificElement) {
this._enableElements(this.elements)
await this._wait(0)
}Set error messages
if (errs.errors && errs.errors.length) {
const elHash = {}
for (const el of this.elements) {
elHash[el.getAttribute('name')] = el
}
for (const err of errs.errors) {
const el = elHash[err.field]
if (el && el.setCustomValidity) {
el.setCustomValidity(err.message)
el.reportValidity()
}
}
}Response hook
await this.response(response, errs, fetchOptions)CASE #3: NO error. Set fields to their new values
} else {Convert the result to JSON
const v = await response.json()
let attempted
if (this.inFlightMap.has(mapIndex)) {
attempted = this.inFlightMap.get(mapIndex).attempted
}
await this.incomingData(v, 'submit')HOOK Set the form values, in case the server processed some values Note: this is only ever called if set-form-after-submit was passed to the form.
if (this.setFormAfterSubmit) {Won’t overwrite fields if another attempt is queued
if (!attempted) {
if (!specificElement) {
this.setFormElementValues(v)
} else {
const name = mapIndex.name
this.setFormElementValues({ [name]: v[name] })
}
}
}Re-enable the elements
if (!specificElement) {
this._enableElements(this.elements)
await this._wait(0)
}Maybe reset the form if it was so required
if (this.resetFormAfterSubmit && !attempted && !specificElement) this.reset()
await this.dataLoaded(v, 'submit')Emit event to make it possible to tell the user via UI about the problem
const event = new CustomEvent('form-ok', { detail: { response }, bubbles: true, composed: true })
this.dispatchEvent(event)Response hook
await this.response(response, v, fetchOptions)
}
if (this.inFlightMap.has(mapIndex)) {
const attempted = this.inFlightMap.get(mapIndex).attempted
this.inFlightMap.delete(mapIndex)
if (attempted) {
this.submit(specificElement)
}
}
}
async updated (changedProperties) {The ‘await’ here has the side effect of waiting for the next tick, which means that children elements will have a chance to render
await super.updated()If no-autoload is set to true, or there is no autoload or no recordId, simply give up: nothing to do
if (this.noAutoload || !changedProperties.has('recordId')) returnRecord ID must be “something”
if (typeof this.recordId === 'undefined' || this.recordId === null) return
return this.preloadData()
}
async preloadData () {Work out the action’s URL, adding the record ID at the end (It will be a get) If there is a result, fetch the element values
const action = this.getAttribute('action')
let response
if (action) {This will make sure that the element is actually visible before doing the fetch
await this.updateCompleteDisable elements
this._disableElements(this.elements)Fetch the data and trasform it to json
let v
try {
const el = this._fetchEl()
response = await el.fetch(action + '/' + this.recordId)
v = await response.json()
} catch (e) {
console.error('WARNING: Fetching element failed to fetch')
v = {}
}
await this.incomingData(v, 'autoload')Set values
this.setFormElementValues(v)Re-enabled all disabled fields
this._enableElements(this.elements)
await this._wait(0)Run reportValidity if validateOnLoad is on
if (this.validateOnLoad) {
this.reportValidity()
}Run hook
await this.dataLoaded(v, 'autoload')
}
}
render () {
return html`
<slot></slot>
`
}
}
tpeRegistry.register('ee-form', EnForm)