-
Notifications
You must be signed in to change notification settings - Fork 653
Architecture
ASF consists of the directive sfSchema
(src/directives/schema-form.js) and a couple of services + some helping directives used
internally to render the form.
Conceptually you can split it up in three parts:
- The
sfSchema
directive. This is what the user uses and is the starting point. - JSON Schema and ASF form definition parsing and mangling. This is mainly the
schemaForm
service (src/services/schema-form.js). - The builder that churns out the HTML from a canonical form definition. It's the
sfBuilder
Basically it is a pipe line:
- It starts off by the
sfSchema
directive gets a form and a JSON schema from the user. - It merges the schema and the form definition to one canonical form definition, basically a normalized form definition with references back to the schema for each field.
-
sfSchema
then calls thesfBuilder
with a decorator and the canical form. It spews out DOM Nodes. We plop it into the form and apply angulars$compile
to hook it all up.
There are roughly speaking two kinds of form definition: the one we get from the user, and the parsed and enhanced version we call the canonical form definition. The latter is what we loop over in the builder to build the DOM Nodes.
It's easier to explain with some examples:
So this is a simple form definition:
var form = [
"name",
{
"key": "description",
"type": "textarea"
}
];
There are a couple of things to notice:
- It's a list
- It's has objects and strings in it. The string
'name'
is a shorthand for "include the field 'name' here with default settings"
A matching schema could look like this:
{
"type": "object",
"properties": {
"name": {
"title": "Item name",
"type": "string"
},
"description": {
"type": "string",
"title": "Item description"
},
"deleted": {
"type": "boolean"
}
},
"required": ["name", "deleted"]
}
Example: http://schemaform.io/examples/bootstrap-example.html#/3231d278b6236fedbf3b
So if we pass these to the schemaForm.merge
function we would get a canonical form back.
A canonical form:
- Always a list of objects
- Keys are always arrays of strings, not just string. This is so we can support complex characters in keys like space, -, " etc.
- It has default options inferred from the JSON Schema, for example a form type depending on the json schema type.
- It has defaults from global options
- It has a reference to the part of the json schema that it matches.
Here is an example of a merge between the schema and the form above:
[
{
"key": ["name"],
"required": true,
"schema": {
"title": "Item name",
"type": "string"
}
"title": "Item name"
"type": "text"
},
{
"key": ["description"]
"schema": {
"title": "Item description",
"type": "string"
},
"title": "Item description",
"type": "textarea"
}
]
Notice that what in the user supplied form was just the string "name" now is an entire object, and that all their own part of the schema embedded.
Also notice that we've extracted if the field is required or not. This is useful to know on each field.
And even though there where a third property in the JSON Schema, "deleted", it didn't pop up in the final form. This is very much design. We like to support having form that only describe a part of a larger schema. This is often very useful, for instance you can have one schema that covers the validation of a "user" object but display it in a series of forms in a wizard or on several tabs etc.
As you can see this is a much nicer format for our builder to loop over and build a form from.
Let's take a look at the actual merging, how it works and how we will need to change it. I will leave a lot of details out to simplify the examples, global options for instance.
This is psuedo code for what the merge does:
function merge(schema, form) {
// First generate a "standard" form defintion from the JSON Schema alone. There are a couple of
// extensible rules that define what JSON Schema type maps against what field type and defaults.
// Also, very importantly, each field object has a reference back to its schema under the
// property "schema". We later use it for validation.
var result = defaults(schema);
// The defaults method results in two things, a form definition with just defaults and an object,
// "lookup", that acts as a mapping between "key" and field object for that key.
var standardForm = result.form;
var lookup = result.lookup;
// Then we loop over the user supplied form definition and merge in the defaults
// (this is simplified since a form definition isn't flat when using nested types like array,
// fieldsets etc, but you get the idea)
var canonical = [];
angular.forEach(form, function(fieldObj) {
// Handle the shorthand version of just a string instead of an object.
if (typeof fieldObj === 'string') {
fieldObj = { key: fieldObj };
}
// Only fields with a key get defaults from the schema. But there are lot's of other fields
// that don't need it, like buttons etc.
if (fieldObj.key) {
var defaultFieldObj = lookup[fieldObj];
// Copy defaults, unless the user already has set that property.
// This gives us title from the schema, a reference to the actual schema etc
angular.forEach(defaultFieldObj, function(value, attr) {
if (fieldObj[attr] === undefined) {
fieldObj[attr] = defaultFieldObj[attr];
}
});
}
canonical.push(fieldObj);
});
return canonical;
}
One key part here is the lookup
object. It maps from the form key (in string form), to the default options
from the schema.
Keys are in their simple form "dot notated", i.e. as you would wite in javascript. For example 'user.address.street'
. To support keys that have hyphen, period, etc (AKA complex keys), the sfPath
service uses ObjectPath lib (https://github.com/mike-marcacci/objectpath) to transform it into a form
with brackets. So 'user.address.street'
becomes '["user"]["address"]["street"]'
.
Keys in the lookup
mapping object are built while recursively traversing the schema. This has several ramifications. Simple objects in a schema has a straight up mapping to the ordinary bracket notation
key format.
For example:
{
"type": "object",
"properties": {
"user": {
"type": "object",
"properties": {
"address": {
"type": "object",
"properties": {
"street": {
"type": "string"
}
}
}
}
}
}
}
The key "[user]['address']['street']" is easy to calculate from this.
But what about arrays?
Well then, to support arrays we have the JSON forms inspired notation of empty brackets to denote an array
, i.e. user.addresses[].street
.
This notation feels quite natural and feels "array like". A problem here is that to support anyOf
and oneOf
we need a similar notation, but none is not really "natural".
You might have realized that we have a problem here: $ref
. To properly support $ref's in the
schema we can't keep merging like this. The problem is that $ref's can make a schema circular and
therefor we end up in an infinite loop!
To solve $ref
support we need to change the pipeline. Instead of creating defaults from the schema first we need to create them on the fly while traversing the user supplied form definition.
We should also consider how we can change the key notation to support more than just arrays. It was there to be compatible with JSON Form, but that is no longer an issue IMHO. A bit ironically, but we could probably get rid of the array notation completely since if we wanted to we could use the schema to infer where the arrays are when creating whatever we bind the form against. But anyOf
or oneOf
is harder since you like to give the user a method to say "given the third schema of the anyOf schemas I like you to use this form definitions". Something like "user.address{2}.street"
could be used.