@mistio/mist-form
Version:
A Webcomponent that renders a form defined by a JSONSchema & an optional UISchema spec
882 lines (845 loc) • 26.8 kB
JavaScript
import { html, css, LitElement } from 'lit';
import '@vaadin/form-layout';
import '@vaadin/button';
import '@polymer/paper-toggle-button';
import { debouncer, mergeDeep } from './utils.js';
import './types/string.js';
import './types/number.js';
import './types/integer.js';
import './types/boolean.js';
import './types/array.js';
import './types/object.js';
export class MistForm extends LitElement {
static get styles() {
return css`
:host {
display: block;
color: var(--mist-form-text-color, #000);
height: 100%;
padding: 0;
margin: 0;
display: -webkit-box;
display: -moz-box;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
align-items: baseline;
justify-content: center;
}
vaadin-form-layout {
margin-bottom: 16px !important;
}
h1,
h2,
h3 {
margin-bottom: 0;
}
.formError {
color: #d60020;
display: inline;
margin-left: 16px;
}
.lds-ripple {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ripple div {
position: absolute;
border: 4px solid #555;
opacity: 1;
border-radius: 50%;
animation: lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.lds-ripple div:nth-child(2) {
animation-delay: -0.5s;
}
lds-ripple {
0% {
top: 36px;
left: 36px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 72px;
height: 72px;
opacity: 0;
}
}
`;
}
static get properties() {
return {
url: { type: String, reflect: true }, // Spec URL
jsonSchema: { type: Object }, // JSONSchema spec
uiSchema: { type: Object }, // UISchema spec
// resolvedSchema: { type: Object }, // JSONSchema spec after importing remote referenced schemas
// dereferencedSchema: { type: Object }, // JSONSchema spec after resolving local references
// evaluatedSchema: { type: Object }, // JSONSchema spec after evaluating oneOf/allOf/ifthen/etc
action: { type: String, reflect: true }, // Where to submit data?
method: { type: String, reflect: true }, // Request method
headers: { type: Object }, // HTTP request headers
subform: { type: Boolean, reflect: true }, // Are we a subform?
toggles: { type: Boolean, reflect: true }, // Do we have a toggle button?
enabled: { type: Boolean, reflect: true },
valid: { type: Boolean, reflect: true }, // All fields validated?
dialog: { type: Boolean, reflect: true }, // Inside a dialog?
submitting: { type: Boolean, reflect: true }, // Form currently submitting
loading: { type: Object }, // Mapping of widgets currently loading
specError: { type: Object }, // Spec loading error
formError: { type: String }, // Form submission error
formData: { type: Object }, // Payload to be submitted
responsiveSteps: { type: Array }, // Form layout configuration
errors: { type: Array }, // Field validation errors
};
}
get payload() {
const ret = {};
const { properties } = this.evaluatedSchema;
if (this.enabled && properties !== undefined) {
Object.keys(properties).forEach(k => {
if (properties[k] !== undefined && !properties[k].omit) {
ret[k] = this.shadowRoot.querySelector(
`.mist-form-field#${k}`
).payload;
}
});
}
return ret;
}
constructor() {
super();
this.valid = true;
this.enabled = true;
this.subform = false;
this.dialog = false;
this.toggles = false;
this.submitting = false;
this.loading = {};
this.method = 'POST';
this.errors = [];
}
connectedCallback() {
super.connectedCallback();
this.addEventListener(
'field-value-changed',
debouncer(this._handleValueChanged.bind(this), 200)
);
this.addEventListener(
'validation-changed',
debouncer(this._validationChanged.bind(this), 200)
);
this.addEventListener('loading', this._loadingStarted);
this.addEventListener('loaded', this._loadingFinished);
if (!this.responsiveSteps) {
this.responsiveSteps = [
// Use one column by default
{ minWidth: '0', columns: 1, labelsPosition: 'top' },
// Use two columns, if the layout's width exceeds 320px
{ minWidth: '320px', columns: 2, labelsPosition: 'top' },
// // Use three columns, if the layout's width exceeds 500px
// { minWidth: '500px', columns: 3, labelsPosition: 'top' },
];
}
}
disconnectedCallback() {
this.removeEventListener('field-value-changed', this._handleValueChanged);
this.removeEventListener('validation-changed', this._validationChanged);
this.removeEventListener('loading', this._loadingStarted);
this.removeEventListener('loaded', this._loadingFinished);
super.disconnectedCallback();
}
willUpdate(changedProperties) {
// Load new spec if url has been updated
if (changedProperties.has('url')) {
if (this.url) {
this.loadSpec(this.url);
}
}
// Reset & resolve new schema
if (changedProperties.has('jsonSchema')) {
// eslint-disable-next-line func-names
this.resolvedSchema = undefined;
this.dereferencedSchema = undefined;
this.evaluatedSchema = undefined;
this.formError = '';
this.submitting = false;
this.resolveSchema();
this.validate();
}
// Update field values after formData updates
if (changedProperties.has('formData') && this.formData !== undefined) {
if (
JSON.stringify(this.formData) !==
JSON.stringify(changedProperties.get('formData'))
) {
this.shadowRoot.querySelectorAll('.mist-form-field').forEach(el => {
if (
this.formData &&
typeof this.formData === 'object' &&
el.enabled
) {
// eslint-disable-next-line no-param-reassign
el.formData = this.formData[el.id];
}
});
this.validate();
}
}
}
updated(changedProperties) {
// eslint-disable-next-line no-console
console.log(`updated(). changedProperties: `, changedProperties);
}
loadSpec(url) {
this.loading.spec = true;
fetch(url)
.then(response => response.json())
.then(async spec => {
const [, path] = this.url.split('#');
if (path) {
if (spec.jsonSchema) {
// eslint-disable-next-line no-param-reassign
spec.jsonSchema.$ref = `#${path}`;
} else {
// eslint-disable-next-line no-param-reassign
spec.$ref = `#${path}`;
}
}
this.jsonSchema = spec.jsonSchema || spec;
this.uiSchema = spec.uiSchema || {};
this.formData = spec.formData || {};
delete this.loading.spec;
})
.catch(error => {
this.specError = error;
delete this.loading.spec;
// eslint-disable-next-line no-console
console.error('Error loading spec:', error);
});
}
// Import remote referenced schemas
resolveSchema(deepcopy = true) {
if (!this.jsonSchema) return;
// Deep copy incoming jsonSchema
if (deepcopy) {
this.resolvedSchema = JSON.parse(JSON.stringify(this.jsonSchema));
}
// Find references to remote URLs
const remoteRefs = this.findRemoteRefs(this.resolvedSchema, true);
if (remoteRefs.length) {
// Import all remote URLs
this.prefetchRefs(remoteRefs);
} else {
this.dereferencedSchema = this.resolveLocalRefs(this.resolvedSchema);
this.evaluatedSchema = this.evalSchema(
this.dereferencedSchema,
this.domValue
);
setTimeout(() => {
this.requestUpdate();
}, 100);
}
}
findRemoteRefs(obj, replace = false) {
let ret = [];
if (!obj || typeof obj !== 'object') return ret;
if (typeof obj.$ref === 'string') {
const [addr, path] = obj.$ref.split('#');
if (addr) {
ret.push(addr);
if (replace === true) {
// eslint-disable-next-line no-param-reassign
obj.$ref = `#${path}`;
}
}
}
// eslint-disable-next-line
Object.keys(obj).forEach(k => {
if (typeof obj[k] === 'object') {
ret = ret.concat(this.findRemoteRefs(obj[k], replace));
}
});
return ret;
}
prefetchRefs(refs) {
Promise.all(refs.map(url => fetch(url).then(resp => resp.json())))
.then(results => {
results.forEach(result => {
Object.keys(result).forEach(k => {
// Merge or copy remote keys
if (
typeof this.resolvedSchema[k] === 'object' &&
typeof result[k] === 'object'
) {
this.resolvedSchema[k] = {
...this.resolvedSchema[k],
...result[k],
};
} else {
this.resolvedSchema[k] = result[k];
}
});
});
})
.then(() => {
this.resolveSchema(false);
this.requestUpdate();
});
}
resolveLocalRefs(obj, refs) {
// eslint-disable-next-line no-param-reassign
if (refs === undefined) refs = [];
const ret = JSON.parse(JSON.stringify(obj));
return this.replaceLocalRefs(ret, refs);
}
replaceLocalRefs(obj, refs, root) {
// eslint-disable-next-line no-param-reassign
if (root === undefined) root = obj;
if (!obj || typeof obj !== 'object') return obj;
if (typeof obj.$ref === 'string') {
refs.push(obj.$ref);
const target = this.resolveLocalRef(obj.$ref, root);
// eslint-disable-next-line
Object.keys(target).forEach(k => {
if (k !== '$ref') {
this.replaceLocalRefs(target[k], refs, root);
if (typeof obj[k] === 'object' && obj[k].length === undefined) {
// eslint-disable-next-line no-param-reassign
obj[k] = { ...obj[k], ...target[k] };
} else if (
typeof target[k] === 'object' &&
target[k].length === undefined
) {
// eslint-disable-next-line no-param-reassign
obj[k] = { ...target[k] };
} else {
// eslint-disable-next-line no-param-reassign
obj[k] = target[k];
}
}
});
// eslint-disable-next-line no-param-reassign
delete obj.$ref;
}
// eslint-disable-next-line
Object.keys(obj).forEach(k => {
// if (k === 'numberEnum') debugger;
// eslint-disable-next-line no-param-reassign
obj[k] = this.replaceLocalRefs(obj[k], refs, root);
});
return obj;
}
resolveLocalRef(ref) {
const [, path] = ref.split('#');
let target = this.resolvedSchema;
const pathArray = path.split('/');
for (let i = 0; i < pathArray.length; i += 1) {
if (pathArray[i] && target[pathArray[i]]) {
target = target[pathArray[i]];
}
}
return target;
}
evalSchema(obj, domValue) {
if (
!obj ||
typeof obj !== 'object' ||
obj.length ||
Object.keys(obj).length === 0
)
return obj;
const ret = {};
mergeDeep(ret, obj);
if (ret.allOf) {
ret.allOf.forEach(i => {
mergeDeep(ret, this.evalSchema(i, domValue));
});
delete ret.allOf;
}
if (ret.oneOf) {
let discriminator = {};
let discriminatorValue;
if (ret.discriminator) {
if (
domValue &&
domValue[ret.discriminator.propertyName] !== undefined
) {
discriminatorValue = domValue[ret.discriminator.propertyName];
}
} else {
discriminator = {
id: '_discriminator',
type: 'number',
enum: ret.oneOf.map((el, i) => i),
enumNames: ret.oneOf.map((el, i) => el.title || `Option ${i}`),
};
if (ret.properties === undefined) ret.properties = {};
ret.properties._discriminator = discriminator;
discriminatorValue = this.domValue && this.domValue._discriminator;
if (typeof discriminatorValue === 'string') {
discriminatorValue = ret.oneOf.findIndex(
i => i.title === discriminatorValue
);
}
}
if (discriminatorValue !== null) {
if (
ret.discriminator &&
ret.discriminator.mapping &&
ret.discriminator.mapping[discriminatorValue]
) {
mergeDeep(
ret,
this.resolveLocalRef(
ret.discriminator.mapping[discriminatorValue],
obj
)
);
let transform = x => x;
if (
this.uiSchema &&
this.uiSchema[ret.discriminator.propertyName] &&
this.uiSchema[ret.discriminator.propertyName].transform
) {
transform = this.uiSchema[ret.discriminator.propertyName].transform;
}
this.formData = transform(this.formData);
}
if (ret.oneOf[discriminatorValue]) {
mergeDeep(
ret,
this.evalSchema(ret.oneOf[discriminatorValue], domValue)
);
}
}
delete ret.oneOf;
}
if (ret.allOf) {
ret.allOf.forEach(i => {
mergeDeep(ret, this.evalSchema(i, domValue));
});
delete ret.allOf;
}
if (ret.anyOf) {
let discriminator = {};
let discriminatorValue;
if (ret.discriminator) {
if (domValue && domValue[ret.discriminator.propertyName]) {
discriminatorValue = domValue[ret.discriminator.propertyName];
}
} else {
discriminator = {
id: '_discriminator',
type: 'array',
uniqueItems: true,
'ui:widget': 'checkboxes',
items: {
type: 'string',
'ui:widget': 'checkboxes',
enum: ret.anyOf.map((el, i) => el.title || i),
},
};
if (ret.properties === undefined) ret.properties = {};
ret.properties._discriminator = discriminator;
discriminatorValue = this.domValue && this.domValue._discriminator;
if (typeof discriminatorValue === 'string') {
discriminatorValue = ret.anyOf.findIndex(
i => i.title === discriminatorValue
);
}
}
if (discriminatorValue !== null && discriminator !== undefined) {
if (discriminatorValue && discriminatorValue.length) {
discriminatorValue.forEach(dv => {
let index = ret.anyOf.findIndex(el => el.title === dv);
if (index === -1) index = dv;
if (ret.anyOf[index]) {
const schema = this.evalSchema(ret.anyOf[index], domValue);
if (schema.title) delete schema.title;
mergeDeep(ret, schema);
} else if (
ret.discriminator &&
ret.discriminator.mapping &&
ret.discriminator.mapping[index]
) {
mergeDeep(
ret,
this.resolveLocalRef(ret.discriminator.mapping[index], obj)
);
}
});
}
}
delete ret.anyOf;
}
if (ret.if) {
let satisfied = true;
Object.keys(ret.if.properties).forEach(k => {
if (
ret.if.properties[k].const &&
this.domValue &&
ret.if.properties[k].const !== this.domValue[k]
) {
satisfied = false;
}
});
if (satisfied) {
mergeDeep(ret, this.evalSchema(ret.then, domValue));
} else if (ret.else) {
mergeDeep(ret, this.evalSchema(ret.else, domValue));
delete ret.else;
}
delete ret.if;
delete ret.then;
}
// eslint-disable-next-line
Object.keys(ret).forEach(
// eslint-disable-next-line func-names
k => {
// eslint-disable-next-line no-param-reassign
ret[k] = this.evalSchema(
ret[k],
typeof domValue === 'object' && k in domValue ? domValue[k] : domValue
);
}
);
return ret;
}
render() {
let loader = '';
let formFields;
if (Object.keys(this.loading).length > 0) {
loader = html`
<div class="loading">
<div class="lds-ripple">
<div></div>
<div></div>
</div>
</div>
`;
}
if (this.uiSchema && this.uiSchema['ui:enabled'] === false) {
this.enabled = false;
}
if (this.specError) {
return html`Failed to load the form spec: ${this.specError}`;
}
if (this.jsonSchema && this.evaluatedSchema) {
if (
this.evaluatedSchema.type === undefined ||
this.evaluatedSchema.type === 'object'
) {
formFields = this.renderObject(
this.evaluatedSchema,
this.uiSchema,
this.formData
);
} else {
formFields = this.renderField({
jsonSchema: this.evaluatedSchema,
uiSchema: this.uiSchema,
formData: this.formData,
});
}
const footer = this.subform
? html``
: html`
<vaadin-horizontal-layout theme="spacing">
${(this.dialog && this.displayCancelButton()) || ''}
${this.displaySubmitButton(this)}
${(!this.dialog && this.displayCancelButton()) || ''}
${this.submitting
? html`<div class="loading">
<div class="lds-ripple">
<div></div>
<div></div>
</div>
</div>`
: ''}
</vaadin-horizontal-layout>
<div class="formError">
${this.formError.length
? html` <p><strong>${this.formError}</strong></p> `
: html`
<p>
<strong
>${this.formError.status}
${this.formError.title}</strong
>
</p>
<p>${this.formError.detail}</p>
`}
</div>
`;
const title =
this.evaluatedSchema.title && !this.toggles
? (this.subform && html`<h2>${this.evaluatedSchema.title}</h2>`) ||
html`<h1>${this.evaluatedSchema.title}</h1>`
: html``;
const description =
this.evaluatedSchema.description && !this.toggles
? html`<p>${this.evaluatedSchema.description}</p>`
: html``;
return html`
<div class="form">
${title} ${description} ${loader}
<vaadin-form-layout
.responsiveSteps="${this.responsiveSteps}"
?hidden=${!this.enabled}
>
<div id="mist-form-fields">${formFields}</div>
</vaadin-form-layout>
${footer}
</div>
`;
}
return html`<paper-spinner active></paper-spinner>`;
}
renderObject(obj, uiSchema, formData) {
const properties = obj.properties || {};
const { required } = obj;
return Object.keys(properties).map(propertyId => {
const defaultValue =
properties[propertyId].value || properties[propertyId].default;
const fieldSpec = {
jsonSchema: properties[propertyId] || {},
uiSchema: (uiSchema && uiSchema[propertyId]) || {},
formData:
(formData && typeof formData === 'object' && formData[propertyId]) ||
defaultValue,
};
fieldSpec.id = propertyId;
if (typeof required === 'boolean') {
fieldSpec.jsonSchema.required = required;
} else if (required && required.findIndex(x => x === propertyId) > -1) {
fieldSpec.jsonSchema.required = true;
}
if (
formData &&
typeof formData === 'object' &&
formData[propertyId] !== undefined
) {
fieldSpec.formData = formData[propertyId];
}
return this.renderField(fieldSpec);
});
}
renderField(fieldSpec) {
// eslint-disable-next-line no-console
console.debug('mist-form', this.id, 'rendering field', fieldSpec.id);
if (!fieldSpec.jsonSchema) {
return '';
}
switch (fieldSpec.jsonSchema.type) {
case 'string':
return html`<mist-form-string-field
id="${fieldSpec.id}"
class="mist-form-field"
.spec=${fieldSpec}
></mist-form-string-field>`;
case 'number':
return html`<mist-form-number-field
id="${fieldSpec.id}"
class="mist-form-field"
.spec=${fieldSpec}
></mist-form-number-field>`;
case 'integer':
return html`<mist-form-integer-field
id="${fieldSpec.id}"
class="mist-form-field"
.spec=${fieldSpec}
></mist-form-integer-field>`;
case 'object':
return html`<mist-form-object-field
id="${fieldSpec.id}"
class="mist-form-field"
.spec=${fieldSpec}
></mist-form-object-field>`;
case 'array':
return html`<mist-form-array-field
id="${fieldSpec.id}"
class="mist-form-field"
.spec=${fieldSpec}
></mist-form-array-field>`;
case 'boolean':
return html`<mist-form-boolean-field
id="${fieldSpec.id}"
class="mist-form-field"
.spec=${fieldSpec}
></mist-form-boolean-field>`;
case 'null':
return html`<mist-form-null-field
id="${fieldSpec.id}"
class="mist-form-field"
.spec=${fieldSpec}
></mist-form-null-field>`;
default:
if (fieldSpec.jsonSchema.length)
return html`<mist-form-array-field
id="${fieldSpec.id}"
class="mist-form-field"
.spec=${fieldSpec}
></mist-form-array-field>`;
return html`<mist-form-string-field
id="${fieldSpec.id}"
class="mist-form-field"
.spec=${fieldSpec}
></mist-form-string-field>`;
}
}
displaySubmitButton() {
if (this.uiSchema && this.uiSchema['ui:submit'] === false) return html``;
return html` <vaadin-button
class="submit-btn"
theme="primary"
?disabled=${!this.valid || this.submitting}
="${this.submitForm}"
>
${this.uiSchema && typeof this.uiSchema['ui:submit'] === 'string'
? this.uiSchema['ui:submit']
: 'Submit'}
</vaadin-button>`;
}
displayCancelButton() {
if (!this.uiSchema || !this.uiSchema['ui:cancel']) return html``;
return html` <vaadin-button
class="cancel-btn"
="${() => this.dispatchEvent(new CustomEvent('mist-form-cancel'))}"
>
${typeof this.uiSchema['ui:cancel'] === 'string'
? this.uiSchema['ui:cancel']
: 'Cancel'}
</vaadin-button>`;
}
submitForm() {
const { payload } = this;
this.submitting = true;
this.formError = {};
this.dispatchEvent(
new CustomEvent('submit', {
detail: {
action: this.action,
method: this.method,
payload,
},
})
);
if (this.action) {
const xhr = new XMLHttpRequest();
xhr.addEventListener('load', e => {
// eslint-disable-next-line no-console
console.log(e.currentTarget.responseText);
this.submitting = false;
this.dispatchEvent(
new CustomEvent('response', {
detail: {
status: e.currentTarget.status,
statusText: e.currentTarget.statusText,
target: e.currentTarget,
},
})
);
if (e.currentTarget.status > 299) {
this.formError = JSON.parse(e.currentTarget.response);
}
});
xhr.open(this.method, this.action);
xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
if (this.headers) {
Object.keys(this.headers).forEach(k => {
xhr.setRequestHeader(k, this.headers[k]);
});
}
xhr.send(JSON.stringify(payload));
}
}
_handleValueChanged(e) {
// eslint-disable-next-line no-console
console.debug('_handleValueChanged', e, this);
e.stopPropagation();
if (!this || !this.jsonSchema) return;
this.formData = { ...this.formData, ...this.domValue };
const eventName = `${this.subform ? 'sub' : ''}form-data-changed`;
const formDataChangedEvent = new CustomEvent(eventName, {
detail: {
id: this.jsonSchema.id,
},
bubbles: true,
composed: true,
});
this.evaluatedSchema = this.evalSchema(
this.dereferencedSchema,
this.domValue
);
this.dispatchEvent(formDataChangedEvent);
this.validate();
}
validate() {
const valid = this._validate();
if (this.valid !== valid) {
this.dispatchEvent(
new CustomEvent('validation-changed', { detail: { valid } })
);
}
return valid;
}
_validate() {
let valid = true;
const errors = [];
this.shadowRoot.querySelectorAll('#mist-form-fields > *').forEach(field => {
const result = field.validate();
valid = valid && result;
if (!result) {
field.errors.forEach(err => {
errors.push(err);
});
}
});
// eslint-disable-next-line no-console
console.debug('form validation', valid, errors);
this.errors = errors;
return valid;
}
_validationChanged(e) {
if (this) {
// eslint-disable-next-line no-console
console.debug('validation-changed', e, this);
this.valid = e.detail.valid;
}
}
_loadingStarted(e) {
this.loading[e.detail] = true;
}
_loadingFinished(e) {
delete this.loading[e.detail];
this.requestUpdate();
}
get domValue() {
let ret;
if (!this.evaluatedSchema) return ret;
switch (this.evaluatedSchema.type) {
case 'object':
ret = {};
this.shadowRoot.querySelectorAll('.mist-form-field').forEach(el => {
ret[el.id] = el.domValue;
});
return ret;
case 'array':
this.shadowRoot.querySelectorAll('.mist-form-field').forEach(el => {
ret = el.domValue;
});
return ret;
default:
return (
this.shadowRoot.querySelector('.mist-form-field') &&
this.shadowRoot.querySelector('.mist-form-field').domValue
);
}
}
}