Skip to content
David Jensen edited this page Mar 8, 2016 · 5 revisions

ASF architecture (work in progress)

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:

  1. The sfSchema directive. This is what the user uses and is the starting point.
  2. JSON Schema and ASF form definition parsing and mangling. This is mainly the schemaForm service (src/services/schema-form.js).
  3. The builder that churns out the HTML from a canonical form definition. It's the sfBuilder

Basically it is a pipe line:

  1. It starts off by the sfSchema directive gets a form and a JSON schema from the user.
  2. 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.
  3. sfSchema then calls the sfBuilder 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.

The form definitions

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:

  1. It's a list
  2. 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:

  1. Always a list of objects
  2. Keys are always arrays of strings, not just string. This is so we can support complex characters in keys like space, -, " etc.
  3. It has default options inferred from the JSON Schema, for example a form type depending on the json schema type.
  4. It has defaults from global options
  5. 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.

The sfSchema.merge method

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;
}

Form keys and the lookup

One key part here is the lookup object. It maps from the form key (in string form), to the default options from the schema.

Small digression into key format

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 lookup

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".

$refs can't fit in this model and what we need to do

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!

The future

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.