@lrnwebcomponents/eco-json-schema-form
Version:
JSON Schema form data binding magic
913 lines (880 loc) • 27.5 kB
JavaScript
import { html, PolymerElement } from "@polymer/polymer/polymer-element.js";
import "@polymer/polymer/lib/elements/dom-if.js";
import { AppLocalizeBehavior } from "@polymer/app-localize-behavior/app-localize-behavior.js";
import { mixinBehaviors } from "@polymer/polymer/lib/legacy/class.js";
import "@polymer/iron-flex-layout/iron-flex-layout-classes.js";
import "./eco-json-schema-array.js";
import "./eco-json-schema-fieldset.js";
import "./eco-json-schema-markup.js";
import "./eco-json-schema-tabs.js";
import "./eco-json-schema-boolean.js";
import "./eco-json-schema-enum.js";
import "./eco-json-schema-file.js";
import "./eco-json-schema-input.js";
/**
`eco-json-schema-object` takes in a JSON schema of type object and builds a form,
exposing a `value` property that represents an object described by the schema.
Given the element:
```
<eco-json-schema-object schema="[[schema]]" value="{{value}}"></eco-json-schema-object>
```
And a JSON schema:
```
> this.schema = {
"$schema": "http://json-schema.org/schema#",
"title": "Contact",
"type": "object",
"properties": {
"name": {
"title": "Name",
"type": "string"
}
}
}
```
A form will be generated, with the elements `value` looking something like this:
```
> this.value
{
"name": "Eric"
}
```
Deep / nested schemas are supported for type array and object:
```
> this.schema = {
"$schema": "http://json-schema.org/schema#",
"title": "Contact",
"type": "object",
"phoneNumbers": {
"title": "Phone numbers",
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"title": "Type",
"type": "string"
},
"phoneNumber": {
"title": "Phone Number",
"type": "string"
}
}
}
}
}
}
```
Validation is handled for strings and number/integers by mapping JSON schema
validation keywords to `paper-input` attributes; form elements will automatically
try and validate themselves as users provide input:
```
> this.schema = {
"$schema": "http://json-schema.org/schema#",
"title": "Contact",
"type": "object",
"properties": {
"name": {
"title": "Name",
"type": "string",
"minLength": 2
},
"age": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true
},
"postalCode": {
"title": "Postal/Zip Code",
"type": "string",
"pattern": "[a-zA-Z][0-9][a-zA-Z]\\s*[0-9][a-zA-Z][0-9]|[0-9]{5}(-[0-9]{4})?"
},
"email": {
"title": "email",
"type": "string",
"format": "email"
}
}
}
```
Customizing components for schema properties is supported by extending your JSON
schema. For any schema sub-property (`properties` for `"type": "object"` and
`items` for `"type": "array"`) a `component` property may be specified, with
the following options:
- `component.name` - specifies the name of the custom component to use
- `component.valueProperty` - specifies which property of the custom element
represents its value
- `component.properties` - properties that will be set on the element
Example schema using custom components (note that `"valueProperty": "value"` is
redundant in this case, `"valueProperty": "value"` will be the default if not specified):
```
> this.schema = {
"$schema": "http://json-schema.org/schema#",
"title": "Contact",
"type": "object",
"properties": {
"phoneNumber": {
"title": "Phone Number",
"type": "string",
"component": {
"name": "gold-phone-input",
"valueProperty": "value",
"properties": {
"countryCode": "1"
}
}
}
}
}
```
Items set in `component.properties` will override any attributes / properties set
by `eco-json-schema-form` elements, making it possible to override JSON schema
validation properties mapped to `paper-input` attributes:
```
> this.schema = {
"$schema": "http://json-schema.org/schema#",
"title": "Contact",
"type": "object",
"properties": {
"postalCode": {
"title": "Postal/Zip Code",
"type": "string",
"pattern": "[a-zA-Z][0-9][a-zA-Z]\\s*[0-9][a-zA-Z][0-9]|[0-9]{5}(-[0-9]{4})?",
"component": {
"properties": {
"autoValidate": false
}
}
}
}
}
```
Putting it all together, this schema:
```
> this.schema = {
"$schema": "http://json-schema.org/schema#",
"title": "Contact",
"type": "object",
"properties": {
"name": {
"title": "Name",
"type": "string",
"minLength": 2
},
"age": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true
},
"postalCode": {
"title": "Postal/Zip Code",
"type": "string",
"pattern": "[a-zA-Z][0-9][a-zA-Z]\\s*[0-9][a-zA-Z][0-9]|[0-9]{5}(-[0-9]{4})?",
"component": {
"properties": {
"autoValidate": false
}
}
},
"phoneNumbers": {
"title": "Phone numbers",
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"title": "Type",
"type": "string"
},
"phoneNumber": {
"title": "Phone Number",
"type": "string",
"component": {
"name": "gold-phone-input",
"valueProperty": "value",
"properties": {
"countryCode": "1"
}
}
}
}
}
},
"emailAddresses": {
"title": "Emails",
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"title": "Type",
"type": "string"
},
"email": {
"title": "email",
"type": "string",
"format": "email"
}
}
}
}
}
}
```
Will build a form describing an object:
```
> this.value
{
"name": "Eric",
"age": 28,
"postalCode": "H1W 2C5",
"phoneNumbers": [
{
"type": "Mobile",
"phoneNumber": "123-456-7890"
}
]
"emailAddresses": [
{
"type": "Personal",
"email": "eric@wat.com"
}
]
}
```
External validation is supported via the `error` property. By providing an
object tree with each leaf representing an error message for properties, the
message will be attached to the appropriate element.
Example, for the Contact schema:
```
el.error = {
"name": "String is too short (0 chars) minimum 2",
"phoneNumbers": [
{
"phoneNumber": "String does not match required format"
}
]
}
```
@group eco Elements
@element eco-json-schema-object
* @demo demo/index.html
*/
class EcoJsonSchemaObject extends mixinBehaviors(
[AppLocalizeBehavior],
PolymerElement
) {
static get tag() {
return "eco-json-schema-object";
}
static get template() {
return html`
<custom-style>
<style is="custom-style" include="iron-flex iron-flex-alignment">
:host {
--eco-json-field-margin: 0 0 15px;
--eco-json-form-border-radius: 2px;
--eco-json-form-font-family: var(
--paper-font-caption_-_font-family,
unset
);
--eco-json-form-bg: var(--primary-background-color, #fff);
--eco-json-form-color: var(--primary-text-color, #222);
--eco-json-form-faded-color: #888;
--eco-json-form-active-color: var(--primary-color, #000);
--eco-json-form-faded-bg: #f0f0f0;
--eco-json-form-add-color: #008811;
--eco-json-form-add-focus: #007700;
--eco-json-form-remove-focus: #cc0000;
--eco-json-form-remove-color: #dd0000;
--simple-picker-float-label-color: var(--eco-json-form-faded-color);
--simple-picker-float-label-active-color: var(
--eco-json-form-active-color
);
--simple-picker-background-color: var(--eco-json-form-bg);
--simple-picker-border-color: var(--eco-json-form-faded-color);
--simple-picker-focus-border-color: var(
--eco-json-form-active-color
);
--simple-picker-focus-border-width: 2px;
--paper-input-container: {
padding-top: 0;
}
}
div.layout {
height: auto;
}
#form {
display: block;
font-family: var(--eco-json-form-font-family);
background-color: var(--eco-json-form-bg);
color: var(--eco-json-form-color);
--simple-tooltip-background: var(--eco-json-form-active-color);
--simple-tooltip-text-color: var(--eco-json-form-bg);
@apply --eco-json-schema-object-form;
@apply --layout-vertical;
@apply --layout-wrap;
}
#form ::slotted(paper-input) {
margin-bottom: 15px;
}
#form ::slotted(*.has-tooltip-desc) {
margin-bottom: 0;
padding-bottom: 0;
--paper-input-container: {
margin-bottom: 0;
padding-bottom: 0;
}
}
#form ::slotted(div.tooltip-desc) {
font-size: 12px;
margin: var(--eco-json-field-margin);
color: var(--eco-json-form-faded-color);
}
#form ::slotted(paper-input),
#form ::slotted(div.tooltip-desc) {
font-family: var(--eco-json-form-font-family);
}
#form ::slotted(div.desc-for-paper-textarea) {
margin-top: -18px;
margin-right: 35px;
}
#form ::slotted(code-editor) {
margin: 8px 0;
padding: 0;
--code-editor-float-label-color: var(--eco-json-form-faded-color);
--code-editor-float-label-active-color: var(
--eco-json-form-active-color
);
--code-pen-button-color: var(--eco-json-form-faded-color);
--code-editor-code-border: 1px solid
var(--eco-json-form-faded-color);
--code-editor-code-border-radius: 2px;
--code-editor-focus-code-border: 2px solid
var(--eco-json-form-active-color);
}
</style>
</custom-style>
<template is="dom-if" if="{{!wizard}}">
<div class="header" hidden$="[[!label]]">[[label]]</div>
</template>
<div class="layout vertical flex start-justified">
<div
id="form"
class="layout horizontal flex start-justified"
aria-live="polite"
>
<slot></slot>
</div>
</div>
`;
}
static get properties() {
return {
language: {
value: "en",
},
resources: {
value() {
return {};
},
},
schema: {
type: Object,
notify: true,
observer: "_schemaChanged",
},
label: {
type: String,
},
value: {
type: Object,
notify: true,
value() {
return {};
},
},
error: {
type: Object,
observer: "_errorChanged",
},
wizard: {
type: Boolean,
notify: true,
},
/**
* the name of the code-editor theme
*/
codeTheme: {
type: String,
value: "vs-light-2",
},
/**
* automatically set focus on the first field if that field has autofocus
*/
autofocus: {
type: Boolean,
value: false,
},
};
}
disconnectedCallback() {
this._clearForm();
super.disconnectedCallback();
}
/**
* returns an array of properties for a given schema object
* @param {object} thisSchema the source schema
* @returns {array} an array
*/
_buildSchemaProperties(thisSchema = this.schema) {
var ctx = this;
return Object.keys(thisSchema.properties || []).map((key) => {
var schema = thisSchema.properties[key];
var property = {
name: key,
schema: schema,
label: schema.title || key,
description: schema.description,
component: schema.component || {},
};
if (!property.component.valueProperty) {
property.component.valueProperty = "value";
}
if (!property.component.slot) {
property.component.slot = "";
}
if (ctx._isSchemaEnum(schema)) {
property.component.name =
property.component.name || "eco-json-schema-enum";
if (typeof schema.value === typeof undefined) {
schema.value = "";
}
property.value = schema.value;
} else if (ctx._isSchemaBoolean(schema.type)) {
property.component.name =
property.component.name || "eco-json-schema-boolean";
if (typeof schema.value === typeof undefined) {
schema.value = false;
}
property.value = schema.value;
} else if (ctx._isSchemaFile(schema.type)) {
property.component.name =
property.component.name || "eco-json-schema-file";
if (typeof schema.value === typeof undefined) {
schema.value = {};
}
property.value = schema.value;
} else if (ctx._isSchemaValue(schema.type)) {
property.component.name =
property.component.name || "eco-json-schema-input";
if (typeof schema.value === typeof undefined) {
schema.value = "";
}
property.value = schema.value;
} else if (ctx._isSchemaObject(schema.type)) {
property.component.name =
property.component.name || "eco-json-schema-object";
if (typeof schema.value === typeof undefined) {
schema.value = {};
}
property.value = schema.value;
} else if (ctx._isSchemaArray(schema.type)) {
property.component.name =
property.component.name || "eco-json-schema-array";
this._buildNestedSchemaProperties(property, schema);
} else if (ctx._isSchemaFieldset(schema.type)) {
property.component.name =
property.component.name || "eco-json-schema-fieldset";
this._buildNestedSchemaProperties(property, schema);
} else if (ctx._isSchemaTabs(schema.type)) {
property.component.name =
property.component.name || "eco-json-schema-tabs";
this._buildNestedSchemaProperties(property, schema);
} else if (ctx._isSchemaMarkup(schema.type)) {
property.component.name =
property.component.name || "eco-json-schema-markup";
if (typeof schema.value === typeof undefined) {
schema.value = "";
}
property.value = schema.value;
} else {
return console.error("Unknown property type %s", schema.type);
}
return property;
});
}
/**
* adds array of nested sub-properties to a given property based on a given schema
* @param {object} property the property that will have nested subproperties
* @param {object} schema the schema that has nested subschemas
*/
_buildNestedSchemaProperties(property, schema) {
if (typeof schema.value === typeof undefined)
schema.value = schema.type !== "array" ? {} : [];
property.value = schema.value;
for (let key in schema.items.properties) {
schema.items.properties[key].value = schema.value[key];
}
property.schema.properties = this._buildSchemaProperties(
schema.items,
property.property + "."
);
}
/**
* updates the value when a schema property (or subproperty) changes
* @param {event} event the
* @param {object} detail the event details
*/
_schemaPropertyChanged(event, detail) {
if (detail) {
if (detail.path && /\.length$/.test(detail.path)) {
return;
}
var ctx = this;
var property = event.target.schemaProperty;
var path = ["value"].concat(
`${event.target.propertyPrefix}${event.target.propertyName}`.split(".")
);
var prop = property.property || event.target.propertyName;
var val = detail && detail.value ? this._deepClone(detail.value) : null;
if (detail.path && /\.splices$/.test(detail.path)) {
var parts = detail.path.split(".").slice(1, -1);
while (parts.length) {
path.push(parts.shift());
if (property.keyMap && property.keyMap[path.join(".")]) {
path = property.keyMap[path.join(".")].split(".");
}
}
if (detail.value.keySplices) {
if (property.keyMap) {
detail.value.keySplices.forEach((splice) => {
splice.removed.forEach((k) => {
delete property.keyMap[path.concat([k]).join(".")];
});
});
}
}
if (detail.value) {
detail.value.indexSplices.forEach((splice) => {
var args = [path.join("."), splice.index, splice.removed.length];
if (splice.addedCount) {
for (var i = 0, ii = splice.addedCount; i < ii; i++) {
if (splice.addedKeys && splice.addedKeys[i]) {
property.keyMap = property.keyMap || {};
property.keyMap[
path.concat([splice.addedKeys[i]]).join(".")
] = path.concat([i + splice.index]).join(".");
}
args.push(ctx._deepClone(splice.object[i + splice.index]));
}
}
ctx.splice.apply(ctx, args);
});
}
} else if (detail.path) {
var parts = detail.path.split(".").slice(1);
var v = this.value;
if (!v.hasOwnProperty(property.property)) {
this.set("value." + property.property, {});
this.notifyPath("value." + property.property);
}
while (parts.length) {
var k = parts.shift();
path.push(k);
if (property.keyMap && property.keyMap[path.join(".")]) {
path = property.keyMap[path.join(".")].split(".");
}
}
this.set(path.join("."), this._deepClone(detail.value));
this.notifyPath(path.join("."));
} else {
//most of our fields will just set the value this way
this.set(path.join("."), this._deepClone(detail.value));
this.notifyPath("value");
}
//fire an event to let array listeners handle changed fields
event.target.dispatchEvent(
new CustomEvent("form-field-changed", {
bubbles: true,
cancelable: true,
composed: true,
detail: event.target,
})
);
this.dispatchEvent(
new CustomEvent("value-changed", {
bubbles: true,
cancelable: true,
composed: true,
detail: this,
})
);
}
}
/**
* sets the value based on a the schema properties (or a subproperties and a path)
* @param {array} props the schema properties (default) or subproperties
* @param {string} path the string that indicates the path for subproperties
*/
_setValue(props = this._schemaProperties, path = "") {
let setter = path.replace(/\[(\d+)\]/g, ".$1");
if (setter != "") setter = `.${setter}`;
if (props.length > 0) {
props.forEach((property) => {
if (typeof property.value !== typeof undefined) {
this.set(
`value${setter}.${property.property}`,
this._deepClone(property.value),
JSON.stringify(property.value)
);
}
});
this.notifyPath("value.*");
}
}
/**
* builds form fields and appends them to an element
* @param {array} props the schema properties (default) or subproperties
* @param {object} container optional container element the for the form fields (for subproperties)
* @param {string} prefix optional field name prefix (for subproperties)
*/
_buildForm(props = this._schemaProperties, container = this, prefix = "") {
let autofocus = this.autofocus;
props.forEach((property) => {
if (property.component.name === "code-editor") {
// special case, can't come up with a better way to do this but monoco is very special case
property.schema.component.properties.editorValue =
property.schema.value;
property.schema.component.properties.theme = this.codeTheme;
}
let propertyPrefix = prefix !== "" ? `${prefix}.` : ``,
propertyName = property.property || property.name;
var el = this.create(property.component.name, {
label: property.label,
schema: property.schema,
schemaProperty: property,
propertyPrefix: propertyPrefix,
propertyName: propertyName,
language: this.language,
resources: this.resources,
});
if (property.component.name === "paper-input") {
el.style["background-color"] = "transparent";
el.style["width"] = "100%";
}
el.setAttribute("name", `${propertyPrefix}${propertyName}`);
if (property.schema.hidden && property.schema.hidden !== undefined) {
el.setAttribute("hidden", property.schema.hidden);
}
//allows the first form fields to be focused on autopmatically
if (autofocus) el.setAttribute("autofocus", autofocus);
//turns of focus on subsequent form fields
autofocus = false;
el.className = "flex start-justified";
// set the element's default value to be what was passed into the schema
el[property.component.valueProperty] = property.value;
// support component attribute overrides
for (var attr in property.component.attributes) {
el.setAttribute(attr, property.component.attributes[attr]);
}
// support component property overrides
for (var prop in property.component.properties) {
el[prop] = property.component.properties[prop];
}
this.listen(
el,
property.component.valueProperty
.replace(/([A-Z])/g, "-$1")
.toLowerCase() + "-changed",
"_schemaPropertyChanged"
);
this.listen(el, "build-fieldset", "_onBuildFieldset");
if (typeof this.$ !== typeof undefined) {
container.appendChild(el);
if (property.description) {
var id = "tip-" + property.property,
tip = document.createElement("div");
el.setAttribute("aria-describedby", id);
el.setAttribute("class", "has-tooltip-desc");
tip.setAttribute("id", id);
tip.setAttribute(
"class",
"tooltip-desc desc-for-" + property.component.name
);
if (property.schema.hidden === true)
tip.setAttribute("hidden", "hidden");
tip.setAttribute("role", "tooltip");
tip.innerHTML = property.description;
container.appendChild(tip);
}
}
// support for slot injection too!
if (property.component.slot != "") {
let temp = document.createElement("div");
temp.innerHTML = property.component.slot;
let cloneDiv = temp.cloneNode(true);
while (cloneDiv.firstChild !== null) {
el.appendChild(cloneDiv.firstChild);
}
}
this.dispatchEvent(
new CustomEvent("form-changed", {
bubbles: true,
cancelable: true,
composed: true,
detail: this,
})
);
});
}
/**
* handles fieldset requests from containers like fieldsets, tabs, or arrays
* @param {event} event the "build-fieldset" event
* @param {object} detail the details of the event, as in:```
{
prefix: the prefix for the fields
properties: [] //an array of schema properties,
container: <div/> //the container element,
}
```
*/
_onBuildFieldset(event, detail) {
if (typeof this.get(`value.${detail.prefix}`) === typeof undefined)
this.set(`value.${detail.path}`, this._deepClone(detail.value));
this._clearForm(detail.container);
this._buildForm(detail.properties, detail.container, detail.prefix);
}
/**
* removes a field element
* @param {object} el the element to remove
* @param {*} parent the container where the field element exists
*/
_removePropertyEl(el, parent = this) {
if (typeof el.schemaProperty !== typeof undefined) {
this.unlisten(
el,
el.schemaProperty.component.valueProperty
.replace(/([A-Z])/g, "-$1")
.toLowerCase() + "-changed",
"_schemaPropertyChanged"
);
}
el.schemaProperty = null;
parent.removeChild(el);
}
/**
* clears a form or a fieldset container within a form
* @param {object} el the element to remove
* @param {*} parent the container where the field element exists
*/
_clearForm(container = this) {
if (typeof this.$ !== typeof undefined) {
var formEl = container;
while (formEl.firstChild) {
this._removePropertyEl(formEl.firstChild, container);
}
}
}
/**
* updates the form when the schema changes
* @param {object} newValue the new value for the schema
* @param {object} oldValue the old value for the schema
*/
_schemaChanged(newValue, oldValue) {
if (newValue && newValue !== oldValue) {
this._clearForm();
this._schemaProperties = this._buildSchemaProperties();
this._buildForm();
this._setValue();
}
}
/**
* handles errors
* @todo how do we want to handle errors for nested fields?
*/
_errorChanged() {
/*
console.log(
"_errorChanged",
this,
this.querySelectorAll("[name]"),
this.error
);*/
this.childNodes.forEach((el) => {
var name = el.getAttribute("name");
if (this.error && this.error[name]) {
el.error = this.error[name];
} else {
el.error = null;
}
});
}
/**
* clones an object and all its subproperties
* @param {object} o the object to clone
* @returns {object} the cloned object
*/
_deepClone(o) {
return JSON.parse(JSON.stringify(o));
}
_isSchemaValue(type) {
return (
this._isSchemaBoolean(type) ||
this._isSchemaNumber(type) ||
this._isSchemaString(type) ||
this._isSchemaFile(type)
);
}
_isSchemaFile(type) {
if (Array.isArray(type)) {
return type.indexOf("file") !== -1;
} else {
return type === "file";
}
}
_isSchemaBoolean(type) {
if (Array.isArray(type)) {
return type.indexOf("boolean") !== -1;
} else {
return type === "boolean";
}
}
_isSchemaEnum(schema) {
return !!schema.enum;
}
_isSchemaNumber(type) {
if (Array.isArray(type)) {
return type.indexOf("number") !== -1 || type.indexOf("integer") !== -1;
} else {
return type === "number" || type === "integer";
}
}
_isSchemaString(type) {
if (Array.isArray(type)) {
return type.indexOf("string") !== -1;
} else {
return type === "string";
}
}
_isSchemaObject(type) {
return type === "object";
}
_isSchemaArray(type) {
return type === "array";
}
_isSchemaFieldset(type) {
return type === "fieldset";
}
_isSchemaTabs(type) {
return type === "tabs";
}
_isSchemaMarkup(type) {
return type === "markup";
}
focus() {
//console.log(this);
}
}
customElements.define(EcoJsonSchemaObject.tag, EcoJsonSchemaObject);
export { EcoJsonSchemaObject };