Editron is a JSON-Editor, which takes a JSON-Schema to generate an HTML form for user input and live validation. The editor can be used as a component in your web application.
demo (coming soon) | getting started | custom editor-widget | API (coming soon)
what does editron do?
- editron is a JSON-Editor, which generates an user interface based on a JSON-Schema
- editron will display the JSON-Schema's title, detailed descriptions, structure and live validation results in an HTML form
- furthermore, it can display data in a custom and appropriate way to improve usability
why use a schema-based editor?
- a JSON-Schema is quick to write, readable and easy to extend
- because it represents all types of JSON data structures, it can be the single interface for all forms
- being JSON and thus serializable it can be distributed over http, stored in a database and read by any programming language
- JSON-Schema is a standard and has a range of validators for many common languages
why use editron
- customizability
- via json-schema
- selection of what to render and where (specific properties, trees or lists within the data)
- extensibility
- custom editor-widgets, framework agnostic
- custom validation methods (sync and async)
- design
- performant
- follows simple concepts in interpreting the JSON-Schema to build an HTML form
- features
- supports collaborative editing,
- live inline validation
- complete json-schema draft04 spec
- support for multiple languages
- tested and used in production
limitations
- requires programming skills for a custom editor-widget
- currently no theming options: for layout adjustments either custom css or custom editor-widgets are required
- not recommended for text-heavy applications like in docs or word
- if you only need a login-form, this project might not be worth the Kb
- complex data-types result in complex user-interfaces. could be solved through specific editor-widgets
There are three basic concepts that you should be familiar, when working with a JSON-Schema based editor:
- JSON-Schema is a declarative format for describing the structure of data and itself is stored as a JSON-file. A JSON-Schema may be used to describe the data you need and also describe the user-interface to create this data.
- JSON-Schema validation extends the JSON-Schema with validation rules for the input values. These rules are used to further evaluate the corresponding data and respond with errors, if the given rules are not met.
- JSON-Pointer defines a string syntax for identifying a specific value within a JSON document and is supported by JSON-Schema. Given a JSON document, it behaves similar to a lodash path (
a[0].b.c
), which follows JS-syntax, but instead uses/
separators, e.g. (a/0/b/c
). In the end, you describe a path into the JSON data to a specific point.
You can copy the following example from ./examples/getting-started.html
1. Add the required dependencies to your application
<!-- editron required fonts and styles -->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/editron.css" rel="stylesheet" />
<!-- editron required dependency -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/mithril/1.1.3/mithril.min.js"></script>
<!-- editron library and main controller, exposed to window.editron -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/editron-modules.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/editron.js"></script>
Note ensure that editron-modules.js
is loaded before editron.js
and any other editors.
2. Create or load a JSON-Schema
<script type="text/javascript">
window.jsonSchema = {
type: "object",
required: ["title"],
properties: {
title: {
title: "simple-string", // any title property will be shown as label
type: "string",
minLength: 1
}
}
};
</script>
3. Initialize the Controller and create an editor
<div class="editor"></div>
<script type="text/javascript">
const Controller = window.editron;
const controller = new Controller(window.jsonSchema);
controller.createEditor("#", document.querySelector(".editor"));
// get the generated data with
const data = controller.getData(); // { title: "" }
// change data
controller.setData({ title: "getting started" });
</script>
The Controller
manages editors and editor-instances. Most of the time, you want to work with the actual data and
validation. Each Controller
-instance will therefore expose three main services: DataService
, ValidationService
and
the SchemaService
.
The DataService
manages the input-data. It keeps a history of all updates (undo/redo) and notifies any observers for
changes on the data or a JSON-Pointer. To get the DataService
-instance, use
const dataService = controller.data();
Access data
// get data matching json-schema
const data = controller.data().get();
// get data from specific JSON-Pointer
const title = controller.data().get("#/title");
// set data
controller.data().set("#", { title: "new title" });
// or change a specific property using JSON-Pointer
controller.data().set("#/title", "new title");
Events
You can listen to any change events or specific properties within the data
// listen to any change
controller.data().on("beforeUpdate", (pointer, action) => {});
controller.data().on("afterUpdate", (pointer, action) => {});
// in order to watch specific properties, use the observable-interface
controller.data().observe("#/title", event => {
const { pointer, parentPointer, patch } = event
});
// you can also watch changes of any childnodes by setting the bubble-option to `true`
controller.data().observe("#/title", event => {}, true);
Undo/Redo
The DataService
-instance also exposes undo/redo states
// get steps
const undoCount = controller.data().undoCount();
const redoCount = controller.data().redoCount();
// and performs undo actions
controller.data().undo();
controller.data().redo();
The ValidationService
manages the validation process, stores and notifies of any input-errors within the data. To get
the ValidationService
-instance, use
const validator = controller.validator();
Access Errors
Anytime you can get a list of current errors and/or warnings. But you should pay attention that, while validating any aysnchronous validation may not be resolved at this time. See the next point Events for handling this situation.
// most of the time, you will be interested in errors
const errors = controller.validator().getErrors();
// but warnings are also supported `{ type: 'warning' }`
const warnings = controller.validator().getWarnings();
// or get both
const problems = controller.validator().getErrorsAndWarnings();
All methods return an array of error-objects, like
// example errorObject
code: "min-length-error"
data: { minLength: 1, length: 0, pointer: "#/title" }
message: "Value `#/title` should have a minimum length of `1`, but got `0`."
name: "MinLengthError"
type: "error"
Events
You can watch any errors or errors on specific data JSON-Pointer. Following the interface for the DataService
// watch any errors using the emitter
controller.validator().on("onError", (errorObject) => {});
// get notified on a new validation-run (clears all current errors)
controller.validator().on("beforeValidation", () => {});
// get notified when a validation has finished
controller.validator().on("afterValidation", (listOfErrors) => {});
// to watch specific properties for occurring errors, use the observable-interface
contoller.validator().observe("#/title", (errorObject) => {});
// or watch all errors, occuring in childnodes, using the bubble-option set to `true`
contoller.validator().observe("#", (errorObject) => {}, true);
Note that onError emits each error individually. To get the current list of errors,
gather all error-events and reset them at the beforeValidation-event. Any observe
-events collect errors and will
notify each update. e.g. For two errors you receive three events []
, [{ error }]
, [{ error }, { error }]
.
Validating data
Data validation is triggered on each change by the Controller
. In order to manually start validation, you can use the
convenience method
controller.validateAll();
instead of controller.validator().validate(controller.data().get());
.
The SchemaService
is a simple wrapper for the json-schema, helping to retrieve a json-schema of a data JSON-Pointer.
To get the SchemaService
-instance, use
const schema = controller.schema();
In order to retrieve a json-schema, e.g. the property title
from the getting-started-example
const titleSchema = controller.schema().get("#/title");
// { minLength: 1, title: "simple-string", type: "string", editron:ui: {...} }
The SchemaService
exposes some helper-methods
// generate data, confirming to the given json-schema
const templateData = controller.schema().getTemplate(jsonSchema);
// add any missing data, according to the json-schema
const validInputData = controller.schema().addDefaultData(inputData, jsonSchema);
Customize base editors from json-schema
From a given JSON-Schema, its properties title
and description
are used for labels and inline-information of the
input-element or group. For all further editron and editor-widget-settings an object "editron:ui"
is supported, where
all editor and ui configurations may be placed. By the base editor-widgets, the following options are supported
property | type | description |
---|---|---|
title | String | set or override the title |
description | String | set or override the description |
attrs | Object | attributes object, passed to the editors html-element |
icon | String | if supported, define the type of material icon |
hidden | Boolean | hide the value from the user-interface |
enum | String[] | ui titles for an enum-selection |
placeholder | String | placeholder, if editor uses an input-element |
Example:
{
type: "object",
"editron:ui": {
attrs: { class: "mmf-card" }, // additional html attributes for the object-editor
title: "SEO-Settings",
description: "",
icon: "panorama_horizontal",
hidden: false, // hide this value
"object:compact": true // (fake) custom setting for the *object*-editor`
}
}
All (custom) editors may support additional configuration settings, which should be checked on their corresponding README.
Array-Editor options:
property | type | description |
---|---|---|
"array:index" | Boolean=false | show list indices |
controls | Object | controls options, for list item manipulation |
controls.add | Boolean=false | additional add item button |
controls.remove | Boolean=true | remove button on each item |
controls.move | Boolean=true | up and down move cursors |
controls.insert | Boolean=true | insert button between elements |
Object-Editor options:
property | type | description |
---|---|---|
collapsed | Boolean? | If defined, adds a collapsible-icon. If true, will hide properties per default. Besides a hidden class, will add a class collapsible to the object-container |
addDelete | Boolean? | Adds an delete option for this object (added automatically on array-items) |
Add additional editors
To add new or custom editors globally, use the plugin interface
const { plugin, Controller } = editron;
plugin.editor(MyCustomEditor);
const controller = new Controller(jsonSchema, data);
Adding editors to a single Controller
-instance, use the options or add them directly. Using options, you build the
complete editors-list
const { editors, plugin, Controller } = editron;
const options = {
editors: [
MyCustomEditor,
editors.OneOfEditor,
...plugin.getEditors,
editors.ArrayEditor,
editors.ObjectEditor,
editors.ValueEditor
]
};
const controller = new Controller(jsonSchema, data, options);
Or add your editor directly to the instance by
const controller = new Controller(jsonSchema, data);
controller.editors.unshift(MyCustomEditor);
Note The order of the editors-list is relevant. Any json-schema will be resolved in order, starting at the first index, a matching editor-Constructor is searched. The first editor to return true (for Class.editorOf) will be used to represent the given json-schema and instantiated. Thus more specific editors should be at the start of list, where more generale editors, like object or default values should come last.
Validators are used to validate input-data for a JSON-Schema. e.g. a schema { type: "string", minLength: 1 }
, tests
if the passed input is a string, another validator checks if the given minLength
-rule passes. You can validate everything,
even remote ressources, which are validated asynchronous.
There can be two types of validators
- a special format validator, which is executed on a schema, like
{ type: "string", format: "my-custom-format" }
or - any custom attribute, like
{ type: number, "my-custom-validator": 42 }
A validator is a function with the following signature
/**
* @param {JSON-Schema-Core} core
* @param {Object} schema - the json schema triggering the validator
* @param {Any} value - the given input data to validate
* @param {String} pointer - JSON-Pointer of the given _value_
* @return {undefined|Object|Promise} undefined or an error-object
* `{type: "error", message: "err-msg", data: { pointer }}`
*/
function validate(core, schema, value, pointer)
You can reference the json-schema-library format-validators for more examples
Adding a format-validator
controller.addFormatValidator("my-custom-format", validator);
Adding a keyword-validator
// @param {datatype} JSON-Schema datatype, to register this attribute. Here: "string"
// @param {keyword} custom attribute to register validator. Here: "my-custom-keyword"
// @param {Function} validation function
controller.addKeywordValidator("string", "my-custom-keyword", validator);
Or use the plugin interface
const { plugin } = editron;
// format-validator
plugin.validator("format-value", function formatValidator() {});
// keyword validator
plugin.keywordValidator(datatype, propertyName, function propertyValidator() {});
For further details, see the json-schema-library
You can change all interface labels, messages and errors. For this, all strings are stored in a simple object, where each property resolves to the given template string.
import i18n from "editron/utils/i18n";
import german from "./languageGerman";
i18n.addLanguage({
strings: {
"editor:mediaimage:metadata": "Bildgröße: {{width}}x{{height}} [{{size}}]",
"editor:wysiwyg:edithtml:tooltip": "HTML Quellcode bearbeiten",
"toolbar:errors:tooltip": "Schnellansicht aller Fehler",
"toolbar:undo:tooltip": "Undo. Letzte Änderung rückgängig machen",
"toolbar:redo:tooltip": "Redo. Letzte Ă„nderung wiederherstellen",
"toolbar:description:tooltip": "Beschreibungstexte ein oder ausblenden"
},
errors: {
"format-url-error": "Die angegebene Wert `{{value}}` ist keine gĂĽltige url",
"maximum-error": "Die Zahl darf nicht größer als {{maximum}} sein.",
"max-length-error": "Die Eingabe ist zu lang: {{length}} von {{maxLength}} erlaubten Zeichen.",
"minimum-error": "Die Zahl muss größer oder gleich {{minimum}} sein",
"min-items-error": "Es mĂĽssen mindestens {{minLength}} Elemente vorhanden sein",
"min-length-error": (controller, error) => {
if (error.data.minLength === 1) {
return "Es wird eine Eingabe benötigt";
}
return render("Der Text muss eine Mindestlänge von {{minLength}} haben (aktuell {{length}}).", error.data);
}
}
});
Besides getting-started, the following examples can be found in the ./examples directory
- see how to create multiple editors from one instance in examples/multiple-editors
- see how to create multiple independent editron instances in examples/multiple-controller
@todo
create a custom interface for a specific type of data/schema
- improve usability of input: ask for data in a better way. where default forms are not a good match (poimap)
- improve usability of form break flow, to hide details from main data
- preview data (feedback/usability): preview for given input data (e.g. image-url)
in general, you can do everything (interface is a dom-element, interaction is performed through data-events)
- hook on a very specific json-schema property
- completely render and grab user input (complete take over)
- extend rendering on spot
- delegate rendering of child editors to editron
To add a custom editor, you need to
- create a custom editor class, according to the details below
- add a static editorOf function for identifying the required json-schema type and
- add the editor class to an editron instance or add it through the global plugin interface
- ensure the JSON-Schema adds the correct format, to trigger the custom editor-widget
Add your editor
Custom editors can be added to the Controller
-instance via the options property, modified directly or added global via
the plugin-helper @see configuration.editors. In the end, all editors are stored within a simple Array
// push the CustomEditor to the start of list to overrule any other search-results (matching editors)
controller.editors.unshift(CustomEditor);
Hook to a schema
Editron will run through this list, searching for a compatible editor-constructor for the specific schema-type. The
first editor, returning true
for the static function CustomEditor.editorOf
will be instantiated (
@see utils/selectEditor). e.g. if no editor will match the json-schema
type: "object"
, a default object-editor will be instantiated.
You can evaluate any json-schema property, options set in json-schema and the associated data:
class CustomEditor {
/**
* Decide, if the given JSON-Schema should use this editor
* @param {String} pointer - JSON-Pointer
* @param {Controller} controller - Controller-instance
* @param {Object} options - options-object as described above
* @return {Boolean} returns `true` if this class should be used for the pased JSON-Pointer
*/
static editorOf(pointer, controller, options) {
// per default, you will want to get the schema of the current JSON-pointer
const schema = controller.schema().get(pointer);
// access data by
// const data = controller.data().get(pointer);
// and evaluate if this is the right editor for the schema
return schema.type === "object" && schema.format === "CustomEditor";
}
constructor(pointer, controller, options) {}
}
The options object
Based on the current JSON-Schema (short: schema
), the options object always passes the following properties
property | schema-options | description |
---|---|---|
description | schema.description | description of the input-field for the user |
hidden | schema["editron:ui"].hidden | if the input-field should be hidden |
id | - | the current editor-id, which should be used in rendering the input-field |
pointer | - | current JSON-pointer |
title | schema.title | the title as given in schema.title or overruled by schema["editron:ui"].title |
The options object will be extended by all properties given in schema.options
and schema["editron:ui"]
, where the
options in editron:ui
will overwrite the properties defined in the default options-object. Additionally, any options
defined by code, e.g. by calling controller.createEditor(pointer, domNode, { dynamic-options })
, will overwrite all
other options.
- @todo working example with editor testpage
Using the optional base class AbstractEditor
, most work for bootstraping is done by its base methods. This leaves the following required methods for a working editor
/**
* A custom editron-editor-widget
*/
class CustomEditor extends AbstractEditor {
static editorOf(pointer, controller, options) {
const schema = controller.schema().get(pointer);
return schema.format === "CustomEditor" && schema.type === "array";
}
constructor(pointer, controller, options) {
// perform required bootstrapping and exposed the DomNode on `this.dom`
super(pointer, controller, options);
// recommended: build your view model for rendering
this.viewModel = {
pointer,
title: options.title,
id: options.id,
errors: this.getErrors(),
description: options.description,
onfocus: () => this.focus(),
onblur: () => this.blur(),
onchange: (value) => this.setData(value)
tags: this.getData()
};
// recommended: initially render editor view
this.render();
}
// required: data has changed, update data and view
update(patch) {
this.viewModel.tags = this.getData();
this.render();
}
// required, when pointer is used: update any pointer references used and redraw
updatePointer(newPointer) {
super.updatePointer(newPointer);
this.viewModel.pointer = newPointer;
this.render();
}
// optional: update errors and view
updateErrors(errors) {
this.viewModel.errors = errors;
this.render();
}
// optional: remove any custom views, created data or listeners
destroy() {
if (this.viewModel) { // ensure this editor was not already destroyed
m.render(this.dom, m.trust("")); // reset the html, removing event-listeners
super.destroy();
this.viewModel = null; // flag as destroyed
}
}
// custom method: render the view to dom
render() {
// example using mithril to update dom
m.render(this.dom, m(CustomView, this.viewModel));
}
}
about update(patch)
The editor will always be notified on a change (same updates are ignored). And thus, the editor must update the view to reflect this change. The default flow is:
- use
this.setData(newValue)
to update the data-source. The input does already reflect this - an
update(patch)
is received and the input-form is rerendered (nonetheless)
in order to improve rendering performance, the patch-object (which is a jsondiffpatch-diff) can be used to minimize required changes in the ui.
about updatePointer(newPointer)
Any editor uses its pointer
to reference data, schema and to set its id to the rendered view. Changing the pointer
is an implementation detail, but is required for a performant user-experience. In case of reordering array-items
(i.e. drag & drop), the main view must rerender all UI-forms, which will become sluggish on large documents. Thus
updatePointer
is required to reuse existing HTML nodes for a performant rendering. The AbstractEditor
will change
all default listeners to the new pointer, but any custom usage of the pointer (and id) must be treated manually.
Note: since version 8, updating pointers of child-editors (using controller.createEditor) is no longer required.
further details
For further detail, check the AbstractEditor implementation or the advanced-section below.
Add editron to your devDependencies npm i editron -D
. And start your webpack config with the following
// webpack.config.js
const plugin = require("editron/plugin/webpack");
module.exports = plugin.createConfig("my-custom-editor.js", "my-custom-editor.scss");
You can install the required dependencies coming with editron by running
npm --prefix node_modules/editron install
in your editor directory. Now, running
npx NODE_ENV=production webpack
in your project, will start bundling the editor to be used with editron-modules.js
.
@todo build setup, testing, bundling, watching, etc
If you are using the above build setup, exporting the editor by
// file: my-custom-editor.js
import plugin from "editron/plugin";
plugin.editor(require("./MyCustomEditor"));
will add your editor by adding the script to your document:
<script src="https://.../editron-modules.js"></script>
<script src="https://.../editron.js"></script>
<!-- the following script will add the editor when loaded -->
<script src="https://.../my-custom-editor.js"></script>
Without the build-setup, you can still call the plugin through const { plugin } = editron;
.
You can also delegate the creation of child-editor back to the controller. Suppose we have an object with a property
time
and want to pass the time property back to the controller:
// this follows default editor creation
const timeDom = this.dom.querySelector(".child"); // target node, to insert child editor
const pointerToTime = `${this.getPointer()}/time`; // build the JSON-Pointer to the time prop
const timeEditor = controller.createEditor(pointerToTime, timeDom); // create child editor for time
// you can also pass options to the editor with
// controller.createEditor(pointerToTime, timeDom, editorOptions);
Note that managing childeditors also requires delegation of the updatePointer
message. In case of the above
time example, you would need to notify the child-editor, as follows:
// within updatePointer(newPointer)
super.updatePointer(newPointer);
// ...
timeEditor.updatePointer(`${this.getPointer()}/time`);
Extending the AbstractEditor
is totally optional. For more custom editor-implementations you can write your own
class, but you must follow the some basic rules, that are further described in AbstractEditor.
@todo
- use render method for string replacement
- create dom-element using helper (attrs, classnames, etc)
- use given id on input-element
- ...
07/2020
with v8
editron is written using typescript. Due to module-syntax, some exports have changed, mainly:
- The EVENTS-object in services is now exported separately and not on its object
import { EVENTS } from "./DataService
- The
main
-module now exports all helpers separately and the controller is exported as default. - All components are exported individually, having no default in
src/components/index.ts
- dependency mitt has been replaced by nanoevents
- test-runner ava has been replaced by mocha
Additionally all source files have been moved to src
-folder, which must be adjusted in the imports
11/2019
with v7
editron has been updated to mithril@2, json-schema-library@4, mithril-material-forms@3. and all editors have new required method setActive(boolean)
to enable or disabled editor input-interaction. Please refer to each library version for Breaking Changes. In short:
- mithril-material-forms has a consistent api for forms. Any callbacks have changes to lowercase mithril-style e.g.
onclick
oronchange
(button, checkbox, input are affected) - json-schema-library has undergone a major change in its api (schema is mostly optional)
- mithril has dropped support for
m.withAttrs