Creating a Preferences Editor

This tutorial will walk you through the process of building a Preference Editor using the Infusion Preferences Framework.

Note: This tutorial is not yet complete. Where more information is still to come, this will be clearly noted.

Introduction

A Preferences Editor is a web application that supports the creation and modification of a user's preferred settings for a device or application. In this tutorial, we'll look at a functional – but very simple – Preference Editor and explain how it works. From there, we’ll learn about more features of the Preferences Framework by adding functionality to the Editor.

Throughout this tutorial, you’ll find links into the documentation for various parts of Infusion and the Preferences Framework. You shouldn’t need to visit these links to follow the tutorial; they’re there in case you’re interested in reading more about something.

Example code

The source code used in this tutorial is one of the examples provided in the Infusion code base, which you can download from github: https://github.com/fluid-project/infusion. You’ll find the code for the simple Preference Editor in the examples folder: https://github.com/fluid-project/infusion/tree/master/examples/framework/preferences/minimalEditor. We recommend you download the Infusion library and load the example code into your favourite editor.

the folder hierarchy of the sample code

Figure 1: Folder hierarchy for the Preference Editor example

Your company, Awesome Cars, has created the world’s first flying car. The example code is a Preference Editor for the car. If you run a local webserver (for example using this approach, or using MAMP) and navigate to the index.html file in a browser, you should see this interface:

The screen of the example Preference Editor

Figure 2: The screen of the example Preference Editor

This Preference Editor has only one preference – a simple boolean setting for the car’s heated seats feature – and a Save button. Try it out: If you check the checkbox and click save, the state of the preference will be stored in a cookie, and when you reload the page, the checkbox will be set to the saved value. Go ahead, try it.

Let’s talk about what we’re seeing in this interface:

The parts of a Preference Editor screen

Figure 3: The parts of a Preference Editor screen
  • This Editor is a full-page editor, so all of what you see is Preferences Editor (the outer, blue dashed line).
  • The content inside the rectangle (outlined with a green dashed line) is called a Panel – a container representing one (or more) preferences. This particular Editor has only one Panel, but a realistic Editor will likely have several. This tutorial will teach you what you need to know to add more Panels to this Editor.
  • Inside the Panel is an Adjuster (outlined by the inner-most, orange dashed line) – the controls for adjusting a particular preference. This Panel has only one Adjuster in it, but you might want to create a Panel that has multiple Adjusters, say, in the case of very closely-related preferences. This tutorial will teach you about creating Panels with multiple Adjusters.

Let’s take a close look at the code.

Primary Schema

The Primary Schema is a document that defines the preferences for the Editor. The Primary Schema for our example Editor is defined in the schemas/primary.js file using the JSON schema format (you can learn about JSON schemas at http://json-schema.org/):

/**
 * Primary Schema
 * This schema defines the "heated seats" preference edited by this preferences
 * editor: its name, type, default value, etc.
 */
fluid.defaults("awesomeCars.prefs.schemas.heatedSeats", {

    // the base grade for the schema;
    // using this grade tells the framework that this is a primary schema
    gradeNames: ["fluid.prefs.schemas"],

    schema: {
        // the actual specification of the preference
        "awesomeCars.prefs.heatedSeats": {
            "type": "boolean",
            "default": false
        }
    }
});

In this code snippet, the Primary Schema is created using a call to the Infusion Framework function fluid.defaults().

fluid.defaults() accepts two arguments:

  1. a string name, and
  2. a JavaScript object containing options for configuring the component.

In the code snippet above, the first argument – the name – is “awesomeCars.prefs.schemas.heatedSeats”. The second argument – the options – is an object containing (in this case) two properties: gradeNames and schema:

gradeNames

Almost every call to fluid.defaults() includes the gradeNames property in the options argument. This property defines the base grade for the component.

In a Primary Schema, the gradeNames property must include the grade “fluid.prefs.schemas”, which is defined by the Preferences Framework. Using this particular grade is what registers this component as a Primary Schema with the Framework. The Framework will automatically record this fact and use this Primary Schema with your Preference Editor.

schema

This property is the actual JSON definition of the preferences for this Preference Editor.

In this particular example, only a single preference is being defined; a boolean called “awesomeCars.prefs.heatedSeats”:

{
    "awesomeCars.prefs.heatedSeats": {
        "type": "boolean",
        "default": false
    }
}

The key in the JSON definition is the name of the preference. This name will be used throughout the Preferences Framework to associate all the components related to the setting. The name can be anything, so long as it is used consistently, but keep in mind that it will be used in the persistent storage for the user's preference, and will be shared with other technologies that may wish to define enactors to respond to it. We recommend that it be thoughtfully namespaced and human-understandable.

The value is an object containing the properties of the preference. Every preference in a Primary Schema must have at least two properties: “type” and “default”. Coming soon: More information about these two properties.

Panel

A Panel is a component responsible for rendering the user interface controls for a preference and tying them to the internal model that represents the preference value. The Panel for the heated seats preference control is defined in the prefsEditor.js file:

/**
 * Panel for the heated seats preference
 */
fluid.defaults("awesomeCars.prefs.panels.heatedSeats", {
    gradeNames: ["fluid.prefs.panel"],

    // the Preference Map maps the information in the primary schema to this panel
    preferenceMap: {
        // the key must match the name of the pref in the primary schema
        "awesomeCars.prefs.heatedSeats": {
            // this key, "model.heatedSeats", is the path into the panel's model
            // where this preference is stored
            "model.heatedSeats": "default"
        }
    },

    // selectors identify elements in the DOM that need to be accessed by the code;
    // in this case, the Renderer will render data into these particular elements
    selectors: {
        heatedSeats: ".awec-heatedSeats"
    },

    // the ProtoTree is basically instructions to the Renderer
    // the keys in the protoTree match the selectors above
    protoTree: {
        // "${heatedSeats}" is a reference to the last part of the model path in the preferenceMap
        heatedSeats: "${heatedSeats}"
    }
});

In this code snippet, the Panel is created using a call to the Infusion Framework function fluid.defaults(), just as the Primary Schema was. As with the Primary Schema, the call to fluid.defaults() is passed two arguments:

  1. a string name ("awesomeCars.prefs.panels.heatedSeats"), and
  2. a JavaScript object containing options for configuring the component – in this case, the Panel.

The screenshot in Figure 2 (above) shows what the Panel looks like to the user: A single checkbox and label, with a header above. The options for configuring this Panel include four properties: gradeNames, preferenceMap, selectors and protoTree:

gradeNames

As we saw with the Primary Schema, any call to fluid.defaults() must refer to any parent grades using the gradeNames property. Panels must use the "fluid.prefs.panel" grade.

preferenceMap

A Panel must have a _ Preference Map_, which maps the information in the Primary Schema into your Panel. Let’s look at this one more closely:
preferenceMap: {
    "awesomeCars.prefs.heatedSeats": {
        "model.heatedSeats": "default"
    }
}

The first line of the Preference Map, “awesomeCars.prefs.heatedSeats”, is the name of the preference. This exactly matches the name we saw in the Primary Schema earlier. The value for this key is a JavaScript object that defines how this particular preference relates to the Panel’s internal data model.

The content of this Preference Map is a key/value pair:

  • The key, “model.heatedSeats”, is an EL path into the Panel’s data model. An “EL path” is just a dot-separated path built from names. In this case, it means “the heatedSeats property of the model property” of the Panel.

  • The value, “default”, is an EL path referencing the “default” property in the Primary Schema.

This Preference Map is saying two things:

  1. The preference called “awesomeCars.prefs.heatedSeats” should be stored in the Panel’s model in a property called heatedSeats, and

  2. the initial value for the property should be taken from the “default” property of the Primary Schema.

  3. A Preference Map can specify other destinations for Primary Schema information, besides the model. We'll see an example of this when we add antoher panel, later in this tutorial.

selectors

A Panel is a view component – a type of Infusion component that has a view, that is, a user interface. In order to maintain a separation between the code and the HTML for the view, the code interacts with the HTML through named selectors: The code only references the name, and a Framework feature called the DOM Binder looks up the relevant DOM node for the name based on the information in this selectors option.

Let’s look at this more closely:

selectors: {
    heatedSeats: ".awec-heatedSeats"
},

The content of a selectors property is a set of key/value pairs. The key is the name of the selector and the value is the selector itself. This property has only one selector, named heatedSeats. The value is the CSS selector ".awec-heatedSeats". This selector references the actual checkbox in the template for the Panel. This template is found in the html/heatedSeats.html file, which looks like this:

<section class="awe-panel">
    <h2>Heated Seats</h2>

    <label for="prefsEd-heatedSeats">Enable the heated seats when the car starts</label>
    <input type="checkbox" id="prefsEd-heatedSeats" class="awec-heatedSeats"/>
</section>

You can see the “awec-heatedSeats” class name on the <input> element.

protoTree

A Panel is also a Renderer component – a type of Infusion component that uses the Infusion Renderer to render the view based on data in the component’s model. The protoTree is the instructions for how the data in the component’s model maps to the template. Let’s look at this more closely:

protoTree: {
    heatedSeats: "${heatedSeats}"
}

A protoTree contains key/value pairs, where

  • the key is a selector name specified in the selectors option, and
  • the value is the specification for what to render into the DOM node referenced by the selector.

Here, the one key heatedSeats refers to the selector named heatedSeats i.e. the reference to the checkbox in the HTML template. The value is a reference to the heatedSeats property of Panel’s data model. In the Infusion Framework, an IoC reference (IoC stands for Inversion of Control) is a reference to an object in the current context using a particular syntax – specifically, the form {context-name}.some.path.segments. Coming soon: More information about IoC references.

Auxiliary Schema

The Auxiliary Schema is a document that specifies all the things needed to actually build the Preference Editor. The Auxiliary Schema for our example Editor is defined in the schemas/auxiliary.js file:

fluid.defaults("awesomeCars.prefs.auxSchema", {
    gradeNames: ["fluid.prefs.auxSchema"],
    auxiliarySchema: {
          // some code not shown
    }
});

Again, we use fluid.defaults() to create the Schema. As with the Primary Schema and the Panel, fluid.defaults() is passed two arguments:

  1. a string name ("awesomeCars.prefs.auxSchema"), and 2) a JavaScript object literal containing configuration options.

Let’s look at the Schema itself in detail:

{
    auxiliarySchema: {
        // the loaderGrade identifies the "base" form of preference editor desired
        loaderGrades: ["fluid.prefs.fullNoPreview"],

        // 'terms' are strings that can be re-used elsewhere in this schema;
        terms: {
            templatePrefix: "html"
        },

        // the main template for the preference editor itself
        template: "%templatePrefix/prefsEditorTemplate.html",

        heatedSeats: {
            // this 'type' must match the name of the pref in the primary schema
            type: "awesomeCars.prefs.heatedSeats",
            panel: {
                // this 'type' must match the name of the panel grade created for this pref
                type: "awesomeCars.prefs.panels.heatedSeats",

                // selector indicating where, in the main template, to place this panel
                container: ".awec-heatedSeats",

                // the template for this panel
                template: "%templatePrefix/heatedSeats.html"
            }
        }
    }
}

An auxiliary schema can be generally divided into two kinds of properties:

  1. top-level members, defining globally-used values, and
  2. per-preference members (one per preference), defining the specific requirements for each preference.
Loader Grade

The loader grade specifies the type of Preference Editor:

{
    loaderGrades: ["fluid.prefs.fullNoPreview"]
}

The Preference Framework provides three pre-defined types of Editor:

  1. separated panel (the default): This is a page-width panel collapsed at the top of the page; it slides down when activated by the user.
  2. full page, no preview: This is a Preference Editor that occupies the full page.
  3. full page, with preview: This is a Preference Editor that occupies the full page, but includes provisions for an iframe in the page to preview any changes made by the Editor.

In the code snippet above, the loaderGrades option is used to specify the “full page, no preview” form.

Templates

The Auxiliary Schema must declare where to find the main HTML template for the Preference Editor. In our example, this template is located in the same folder as other HTML templates. The Auxiliary Schema allows you to define terms – strings that can be re-used elsewhere in the schema. Here, it is being used to define, in a single place, the path to where the HTML templates are:

{
    terms: {
        templatePrefix: "html"
    }
}

The template property specifies the main HTML template for the entire Preference Editor:

{
    template: "%templatePrefix/prefsEditorTemplate.html"
}

You can see the full text of this file, prefsEditorTemplate.html, in the github repo: https://github.com/fluid-project/infusion/tree/master/examples/framework/preferences/minimalEditor/html/prefsEditorTemplate.html The main thing to note in the template is the placeholder for the Panel, in this example a <div> with the class awec-heatedSeats:

<!-- placeholder for the heated seats preference panel -->
<div class="awec-heatedSeats"></div>The Framework will insert the constructed Panel into this div

The Framework will insert the constructed Panel into this <div>.

Preferences

The next thing in the Auxiliary Schema is the configuration for the heated seats preference:

{
    heatedSeats: {
        // this 'type' must match the name of the pref in the primary schema
        type: "awesomeCars.prefs.heatedSeats",

        panel: {
            // this 'type' must match the name of the panel grade created for this pref
            type: "awesomeCars.prefs.panels.heatedSeats",

            // selector indicating where, in the main template, to place this panel
            container: ".awec-heatedSeats",

            // the template for this panel
            template: "%templatePrefix/heatedSeats.html"
        }
    }
}

(The name of the property, heatedSeats, can actually be anything, but it’s helpful to use the name of the preference.)

In our example, the heated seats preference configuration includes two things:

  1. the type of the preference, and
  2. information about the Panel.

The value of the type property is the name of the preference as defined in the Primary Schema.

The value of the panel property is a JavaScript object containing configuration information for the Panel. Let’s look at each of the properties:

type

This is the name of the Panel that was defined in the call to fluid.defaults() above.

container

This is a CSS-based selector referencing the Panel’s placeholder element in the main HTML template – the one referenced by the template property above.

template

This is the path and filename of the HTML template for this Panel. Notice, in this example, how the templatePrefix term is being used.

Instantiation

The last thing in the js/prefsEditor.js file is a call to the Preferences Framework function fluid.prefs.create(). This function actually creates the Preference Editor. It accepts two arguments:

  1. a CSS selector indicating the container element for the Preference Editor, and
  2. a JavaScript object containing configuration information for the Preference Editor.
awesomeCars.prefs.init = function (container) {
    return fluid.prefs.create(container, {
        build: {
            gradeNames: ["awesomeCars.prefs.auxSchema"]
        }
    });
};

This function is invoked in the main HTML file for the Preference Editor, index.html. You can see the entire file here: https://github.com/fluid-project/infusion/tree/master/examples/framework/preferences/minimalEditor/index.html. Let’s look at this invocation:

<div id="preferencesEditor"></div>

<script type="text/javascript">
    awesomeCars.prefs.init("#preferencesEditor");
</script>

In the HTML snippet above, the <div> is the container that the Preference Editor will be rendered inside of. The call to awesomeCars.prefs.init() is passed the ID of the element, “#preferencesEditor”, as the container argument.

In the code snippet above, the first argument – container – is the CSS identifier passed to the function. The second argument – the options – is an object containing (in this case) one property: build. This option is a JavaScript object containing information that will be passed to the Builder, a key part of the Preferences Framework. The Builder is the core component responsible for actually building the Preference Editor based on all of the configuration information for the preferences, the Panels, etc. For our simple Preference Editor, the build options contains only one property: The grade name of our Auxiliary Schema:

{
    build: {
        gradeNames: ["awesomeCars.prefs.auxSchema"]
    }
}

The Auxiliary Schema (plus the Primary Schema that was registered with the Framework automatically) contains all the information the Builder needs to construct the Preference Editor.

Adding another preference

Let’s use what we’ve learned so far to add another simple preference to the Editor: Preferred volume for the radio. This preference will be a number, and it will have a range of possible values.

To add this preference, we’ll need to

  1. define the preference,
  2. create the Panel, and
  3. add the Panel to the Editor.

Defining the preference

We’ll edit the Primary Schema definition in schemas/primary.js to add the new preference definition. It’s type is “number”, and in addition to the default, we’ll have to define a minimum and maximum, as well as the step value:

fluid.defaults("awesomeCars.prefs.schemas.radioVolume", {
    gradeNames: ["fluid.prefs.schemas"],
    schema: {
        "awesomeCars.prefs.radioVolume": {
            "type": "number",
            "default": "2",
            "minimum": "1",
            "maximum": "5",
            "divisibleBy": "0.5"
        }
    }
});

Creating the Panel

Template and Adjuster

We will need an HTML template for the Panel. Since the preference is a range, we’ll use a slider for the Adjuster.

Create a new file in the html folder called radioVolume.html and use a structure similar to the one already used for the heated seats template:

<section class="awe-panel">
    <h2>Radio Volume</h2>

    <label for="prefsEd-radioVolume">Set the desired volume for the radio</label>
    <input type="range" id="prefsEd-radioVolume" class="awec-radioVolume"/>
</section>

We’ve used an <input> with type “range” for the Adjuster. The template doesn’t need to set the min, max or value attributes; those are dependent on the Primary Schema and will be added in by the Preference Editor.

Panel component

In the prefsEditor.js file, we'll create the Panel component for this preference. As with the heated seats Panel, we use a call to fluid.defaults() and set the grade to “fluid.prefs.panel”:

fluid.defaults("awesomeCars.prefs.panels.radioVolume", {
    gradeNames: ["fluid.prefs.panel"]
    // options will go here...
});

As with the heated seats Panel, we need a Preference Map:

fluid.defaults("awesomeCars.prefs.panels.radioVolume", {
    gradeNames: ["fluid.prefs.panel"],
    preferenceMap: {
        "awesomeCars.prefs.radioVolume": {
            "model.radioVolume": "default"
        }
    }
    // ...
});

The Panel component also needs to know about the minimum, maximum and step value defined in the Primary Schema. These values are not likely to change over the life of the component, so it’s not really appropriate to store them in the model. Instead, we create a range property as a component option:

fluid.defaults("awesomeCars.prefs.panels.radioVolume", {
    gradeNames: ["fluid.prefs.panel"],

    preferenceMap: {
        "awesomeCars.prefs.radioVolume": {
            "model.radioVolume": "default"
        }
    },
    range: {
        min: 1,
        max: 10,
        step: 1
    }
    // ...
});

Finally, the Preference Map needs to tell the component to map the Primary Schema values into the range property:

fluid.defaults("awesomeCars.prefs.panels.radioVolume", {
    gradeNames: ["fluid.prefs.panel"],
    preferenceMap: {
        "awesomeCars.prefs.radioVolume": {
            "model.radioVolume": "default",
            "range.min": "minimum",
            "range.max": "maximum",
            "range.step": "divisibleBy"
        }
    },
    range: {
        min: 1,
        max: 10,
        step: 1
    }
    // ...
});

As we saw with the heated seats Panel, we need to define a selector to identify the HTML element where the preference value will be bound:

fluid.defaults("awesomeCars.prefs.panels.radioVolume", {
    gradeNames: ["fluid.prefs.panel"],
    preferenceMap: {
        "awesomeCars.prefs.radioVolume": {
            "model.radioVolume": "default",
            "range.min": "minimum",
            "range.max": "maximum",
            "range.step": "divisibleBy"
        }
    },
    range: {
        min: 1,
        max: 10,
        step: 1
    },
    selectors: {
        radioVolume: ".awec-radioVolume"
    }
    // ...
});

Finally, we need to define the Renderer protoTree – the instructions for rendering the model value into the template. This protoTree will need to be a little bit more complicated than what we used for the heated seats preference. That protoTree needed to do only one thing: bind the model value to the input element. For the range input, we still need to bind the model value, but we also need to set the min, max and step attributes of the element. For this, a simple key/value pair isn’t enough. Instead of a simple string reference as the value, we’ll use an object with a value property:

{
    protoTree: {
        radioVolume: {
          value: "${radioVolume}"
        }
    }
}

To this, we will add a Renderer Decorator to set the attributes using the contents of the range property:

{
    protoTree: {
        radioVolume: {
          value: "${radioVolume}",
          decorators: [{
              type: "attrs",
              attributes: {
                  min: "{that}.options.range.min",
                  max: "{that}.options.range.max",
                  step: "{that}.options.range.step"
              }
          }]
        }
    }
}

So, this is what our final radio volume Panel component definition looks like:

fluid.defaults("awesomeCars.prefs.panels.radioVolume", {
    gradeNames: ["fluid.prefs.panel"],

    preferenceMap: {
        "awesomeCars.prefs.radioVolume": {
            "model.radioVolume": "default",
            "range.min": "minimum",
            "range.max": "maximum",
            "range.step": "divisibleBy"
        }
    },

    range: {
        min: 1,
        max: 10,
        step: 1
    },

    selectors: {
        radioVolume: ".awec-radioVolume"
    },

    protoTree: {
        radioVolume: {
            value: "${radioVolume}",
            decorators: [{
                type: "attrs",
                attributes: {
                    min: "{that}.options.range.min",
                    max: "{that}.options.range.max",
                    step: "{that}.options.range.step"
                }
            }]
        }
    }
});

Adding the Panel to the Editor

We saw above that the main HTML template for the tool, html/prefsEditorTemplate.html, has a placeholder in it for the heated seats Panel. We will add another placeholder for the radio volume Panel:

<!-- placeholder for the heated seats preference panel -->
<div class="awec-heatedSeats"></div>

<!-- placeholder for the radio volume preference panel -->
<div class="radioVolume"></div>

We’ll also need a property in the Auxiliary Schema, in schemas/auxiliary.js, to provide the configuration options specific to the new Panel:

{
    radioVolume: {
        type: "awesomeCars.prefs.radioVolume",
        panel: {
            type: "awesomeCars.prefs.panels.radioVolume",
            container: ".awec-radioVolume",
            template: "%templatePrefix/radioVolume.html"
        }
    }
}

Saving Preferences

Right now, when you click the "save" button, the preferences are saved – if you reload the page, they're all there. How does that happen? Where are they saved to? And how would you change that?

By default, the Preferences Framework automatically saves the preferences to a browser cookie. How does that happen?

  • The template has a specific class on the "save" button: flc-prefsEditor-save.
  • The Preferences Framework automatically binds a click handler to anything with that class.
  • The click handler ultimately invokes the set method on the default settings store, which is a CookieStore.

Cookies are great for websites, but this is a car. The preferences need to be saved to the car's internal storage. We need to a) create a Settings Store that will save to the internal storage and b) tell the preferences editor to use that instead.

The first step is to create a grade that uses the built-in fluid.prefs.store:

fluid.defaults("awesomeCars.prefs.store", {
    gradeNames: ["fluid.prefs.store"]
});

We'll need to override the default get() and set() methods with our own versions. These methods are implemented as invokers, which makes it easy to plug in our own functions:

fluid.defaults("awesomeCars.prefs.store", {
    gradeNames: ["fluid.prefs.store"],
    invokers: {
        get: {
            funcName: "awesomeCars.prefs.store.get"
        },
        set: {
            funcName: "awesomeCars.prefs.store.set",
            args: ["{arguments}.0"]
        }
    }
});

Our get and set functions will need to do whatever is necessary to save and retrieve the preferences to the car's internal data storage:

fluid.defaults("awesomeCars.prefs.store", {
    gradeNames: ["fluid.prefs.store"],
    invokers: {
        get: {
            funcName: "awesomeCars.prefs.store.get"
        },
        set: {
            funcName: "awesomeCars.prefs.store.set",
            args: ["{arguments}.0"]
        }
    }
});

awesomeCars.prefs.store.get = function () {
    // do whatever you need to do, to retrieve the settings
    return settings;
};
awesomeCars.prefs.store.set = function (settings) {
    // do whatever you need to do to store the settings
};

Finally, we need to tell the preferences editor to use our new settings store instead of the default cookie store. We do this by using the storeType option when we create the editor (as we saw back in Instantiation):

awesomeCars.prefs.init = function (container) {
    return fluid.prefs.create(container, {
        build: {
            gradeNames: ["awesomeCars.prefs.auxSchema"]
        },
        prefsEditor: {
            // specify the settings store to use
            storeType: "awesomeCars.prefs.store"
        }
    });
};

Coming Soon:

Information about:

  • Enactors
  • More complicated Panels
  • Localization
  • Design consideration
  • Case studies