Tutorial: Saving Modifications to Station

Saving Modifications to Station

This tutorial follows from Getting Started - MyFirstWidget.

We're going to modify MyFirstWidget to enable a user to make changes to the Ramp Component on the Station through your Widget.

We will also introduce Templates, Lexicons, and CSS to simplify your modular HTML design.

Outline

+- myFirstModule
   +- myFirstModule-ux
      +- src
      |  +- com
      |  |  +- companyname
      |  |     +- myFirstModule
      |  |        +- ux
      |  |           +- BMyFirstWidget.java
      |  +- rc
      |     +- templates
      |     |  +- MyFirstWidget.hbs -------------------------------------- *NEW*
      |     +- myFirstModule.css ----------------------------------------- *NEW*
      |     +- MyFirstWidget.js --------------------------------------*MODIFIED*
      +- module.lexicon -------------------------------------------------- *NEW*
      +- myFirstModule-ux.gradle.kts
  • MyFirstWidget.hbs: a template file holding the HTML for MyFirstWidget.
  • myFirstModule.css: a file holding all the module's CSS.
  • module.lexicon: a file holding the user-facing text elements of all the module's Widgets.

Templates

Our HTML will be getting a bit more complicated in this next example.

In the first tutorial, we had a simple HTML input DOM element that was updated every time the out Property of a Ramp Component changed on the server. Now, we're going to make our Widget interactive: we're going to add two additional form elements, a text input and check box, to allow the user to view and edit the Ramp's amplitude and enabled properties.

Piecing this HTML together within the JavaScript itself would make it harder to read and more difficult to edit in the future, so let's look at another way to keep the HTML looking like HTML. In this example, our Widget is going to use a client side JavaScript template library called Handlebars. This allows us to keep our HTML in a separate template file and then require it into our Widget.

(The Handlebars templating method is easy, and requires no additional setup. In an upcoming tutorial, we'll look at a couple other templating methods that may fit your use case better.)

MyFirstWidget.hbs

<div>
  <label>{{valueLabel}}: <label>
  <input class='MyFirstWidget-value' type='text' value='{{loadingPlaceholder}}' readonly='readonly'/>
</div>
<div>
  {{changeValuesText}}
</div>
<div>
  <label>{{amplitudeLabel}}: <label>
  <input class='MyFirstWidget-amplitude' type='text' value='{{loading}}' />
</div>
<div>
  <label>{{enabledLabel}}: <label>
  <input class='MyFirstWidget-enabled' type='checkbox' value='Enabled' />
</div>

Here, our template will generate the contents of the DOM element in which our Widget lives. After initialization, our Widget's DOM element will contain four <div> child elements.

The file name does not need to match, but this convention helps easily distinguish which templates belong to which Widgets.

{{variables}} within double brackets will be filled in with actual values that we provide when we generate the templated HTML in the doInitialize() method below.

Modifying, Reading, and Saving a Widget

We can now add some additional methods to our JavaScript Widget to handle DOM modification, reading and saving the data back to the Server. We're really just implementing the behavior at the touchpoints of a Widget's lifecycle detailed in the README, so please refer back to those as needed.

Please skim over the new code for MyFirstWidget.js, then we'll discuss it in detail.

MyFirstWidget.js - modified

/**
 * A module defining `MyFirstWidget`.
 *
 * @module nmodule/myFirstModule/rc/MyFirstWidget
 *
 * Note that certain module IDs include an exclamation point (!). The '!'
 * character indicates the use of a RequireJS plugin. The plugins used for this
 * module include:
 *
 * - BajaScript (baja!): ensures that BajaScript has fully started
 * - Handlebars (hbs!): import Handlebars templates
 * - CSS (css!): import a CSS stylesheet for this `Widget`
 */
define([
  'baja!',
  'hbs!nmodule/myFirstModule/rc/templates/MyFirstWidget',
  'lex!myFirstModule',
  'bajaux/Widget',
  'bajaux/mixin/subscriberMixIn',
  'bajaux/util/SaveCommand',
  'jquery',
  'Promise',
  'css!nmodule/myFirstModule/rc/myFirstModule' ], function (
  baja,
  template,
  lexs,
  Widget,
  subscriberMixIn,
  SaveCommand,
  $,
  Promise) {

  'use strict';

  const [ myFirstModuleLex ] = lexs;

  const widgetDefaults = () => ({
    properties: { rootCssClass: 'MyFirstWidget' }
  });
  
  /**
   * An editor for working with `kitControl:Ramp` instances.
   *
   * @class
   * @extends module:bajaux/Widget
   * @alias module:nmodule/myFirstModule/rc/MyFirstWidget
   */
  return class MyFirstWidget extends Widget {
    constructor(params) {
      super({ params, defaults: widgetDefaults() });
      subscriberMixIn(this);

      // Add a Save Command to allow the user to save the `Widget`.
      this.getCommandGroup().add(new SaveCommand());
    }

    /**
     * Initialize the `MyFirstWidget`.
     *
     * Update the contents of the DOM in which the `Widget` is initialized. This
     * function uses the Handlebars template we imported to generate the HTML.
     *
     * This function also sets up jQuery event handlers. By default, handlers
     * registered on the `dom` parameter, like then ones we arm in this function,
     * will be automatically cleaned up when the `Widget` is destroyed. Any
     * additional handlers (on child elements of the `dom` parameter, say, or on
     * elements outside of this `Widget`) would need to be cleaned up in
     * `doDestroy()` in order to prevent memory leaks.
     *
     * @param {JQuery} dom - The DOM element into which to load this `Widget`
     */
    doInitialize(dom) {
      // The template function returns the contents of MyFirstWidget.hbs, but with
      // variables like {{valueLabel}} filled in using the properties of the object
      // argument.
      dom.html(template({
        valueLabel: myFirstModuleLex.get('MyFirstWidget.value'),
        changeValuesText: myFirstModuleLex.get('MyFirstWidget.changeValues'),
        amplitudeLabel: myFirstModuleLex.get('MyFirstWidget.amplitude'),
        enabledLabel: myFirstModuleLex.get('MyFirstWidget.enabled'),
        loadingPlaceholder: myFirstModuleLex.get('MyFirstWidget.loading')
      }));
      
      this.getSubscriber().attach('changed', () => this.$updateDom(this.value()));

      // When the user makes a change, mark the `Widget` as modified.
      dom.on('input', '.MyFirstWidget-amplitude', () => {
        this.setModified(true);
      });
      
      dom.on('change', '.MyFirstWidget-enabled', () => {
        this.setModified(true);
      });
    }

    /**
     * Update the DOM to show the Ramp's current values.
     *
     * @param {baja.Component} ramp - an instance of `kitControl:Ramp`
     */
    doLoad(ramp) {
      this.$updateDom(ramp);
    }

    /**
     * Update the DOM to reflect the given Ramp's current values.
     * @private
     * @param {baja.Component} ramp a `kitControl:Ramp`
     */
    $updateDom(ramp) {
      this.$getValueDisplay().val(ramp.getOutDisplay());

      // Only update the editable DOM if the user hasn't made unsaved changes.
      if (!this.isModified()) {
        const amplitudeInput = this.$getAmplitudeInput();

        // Don't reset the user's cursor every time the value refreshes if the
        // input box has focus. They may be trying to select or edit the
        // contents.
        if (!amplitudeInput.is(':focus')) {
          amplitudeInput.val(ramp.getAmplitudeDisplay());
        }
        
        this.$getEnabledCheckbox().prop('checked', ramp.getEnabled());
      }
    }

    /**
     * Reads out the `enabled` and `amplitude` values that the user has currently entered.
     *
     * @returns {module:nmodule/myFirstModule/rc/MyFirstWidget~RampProperties}
     */
    doRead() {
      return {
        enabled: this.$getEnabledCheckbox().is(':checked'),
        amplitude: parseFloat(this.$getAmplitudeInput().val())
      };
    }

    /**
     * Save the user-entered changes to the loaded `kitControl:Ramp`.
     *
     * Note that the parameter to this function is the same as that resolved by
     * doRead().
     *
     * @param {module:nmodule/myFirstModule/rc/MyFirstWidget~RampProperties} readValue
     * @returns {Promise}
     */
    doSave(readValue) {
      const ramp = this.value();
      const { enabled, amplitude } = readValue;

      // Return a Promise so that the framework knows when the save has completed.
      return Promise.all([
        ramp.set({ slot: 'enabled', value: enabled }),
        ramp.set({ slot: 'amplitude', value: amplitude })
      ]);
    }

    /**
     * @private
     * @returns {JQuery} the text box for the `amplitude` property
     */
    $getAmplitudeInput() {
      return this.jq().find('.MyFirstWidget-amplitude');
    }
    
    /**
     * @private
     * @returns {JQuery} the checkbox for the `enabled` property
     */
    $getEnabledCheckbox() {
      return this.jq().find('.MyFirstWidget-enabled');
    }

    /**
     * @private
     * @returns {JQuery} the readonly text box showing the Ramp's value
     */
    $getValueDisplay() {
      return this.jq().find('.MyFirstWidget-value');
    }
  };

  /**
   * @typedef module:nmodule/myFirstModule/rc/MyFirstWidget~RampProperties
   * @property {boolean} enabled - whether the Ramp is enabled
   * @property {number} amplitude - the Ramp's amplitude
   */
});

In the constructor, we're specifying a rootCssClass of MyFirstWidget. This means that whenever an instance of MyFirstWidget is initialized, its DOM element will receive the CSS class of MyFirstWidget, which we can use for styling and selection. We are also adding a SaveCommand to the Widget's CommandGroup. When you view MyFirstWidget in the browser or Workbench, this SaveCommand will appear in the toolbar, so you can click it to save the Widget.

In doInitialize(), note that we are using the Handlebars template to set up the initial contents of the DOM element. We're also adding event handlers to ensure that when the user changes the entered values, we mark the Widget as modified. This is very important - without it, the SaveCommand will never enable.

We apply the current properties of the Ramp to the input fields in the DOM both in doLoad() and in the changed event handler, as before.

doRead() is new. When implementing a Widget that allows user edits, doRead() should resolve a value that represents what the user currently has entered. Since our Widget has a checkbox for enabled and a number input for amplitude, we resolve an object with these two properties. This object represents "what values for enabled and amplitude the user has currently entered." (Note the @typedef JSDoc tag describing this data structure - this is not at all required, but can be helpful for readability and autocomplete.)

doSave() is also new. When a Widget is save()d, it will read() the current value out, validate it, and pass it to doSave() for you - so there's no need to call read() again. The implementation of doSave() should mutate the currently loaded value. So we do - we use the BajaScript set() method to set the slot values of the currently loaded Ramp.

Lexicons

Lexicons are how Niagara allows its user interface to be localized for different languages. When a string of text needs to be shown to the user, it's best not to hardcode that string into your source code, because then it can't be localized to a different language. Instead, pull user-facing text from the Lexicon whenever possible. This makes it easy for your UI to be translated for your users in different countries and locales.

The lex! plugin, used in this example, allows you to pull lexicon data from the module of your choice and easily reference it in your UI code. This works very well in conjunction with using variables in your HTML templates.

module.lexicon

#
# Lexicon for the my first module ux.
#

MyFirstWidget.value=Value
MyFirstWidget.changeValues=Changing these inputs modifies the Widget, not the component. You must Save to push the changes to the station.
MyFirstWidget.amplitude=Amplitude
MyFirstWidget.enabled=Enabled
MyFirstWidget.loading=Loading...

Default Values

You can also set default values for lexicon entries within a JavaScript lex.get() function call. This will help if module.lexicon is ever unavailable. Even though this code allows the Widget to function without a
module.lexicon file, you should still create one so that the user can easily find and customize the Widget text, all in one place.

dom.html(template({
  value: lex.get({
    key: "MyFirstWidget.value",
    def: "Value"
  }),
  changeValues: lex.get({
    key: "MyFirstWidget.changeValues",
    def: 'Changing these inputs modifies the Widget, not the ' +
    'component. You must Save to push the changes to the station.'
  }),
  amplitude: lex.get({
    key: "MyFirstWidget.amplitude",
    def: "Amplitude"
  }),
  enabled: lex.get({
    key: "MyFirstWidget.enabled",
    def: "Enabled"
  }),
  loading: lex.get({
    key: "MyFirstWidget.loading",
    def: "Loading..."
  })
}));

CSS

The CSS RequireJS plug-in is being used to import a style sheet for the Widget. Please note, since there may be other bajaux Widgets running alongside yours in the same view importing their own CSS, you must make your CSS selectors unique. An easy way to do this is to include your Widget's name in the selector, since that must also be unique already (e.g. MyFirstWidget).

In this example, we just reuse the widget name as the class name, but if you are concerned about collisions with other modules that might include Widgets with the same name as yours, you can use whatever naming convention you like, such as myCompanyName-MyFirstWidget.

(One note about the ordering of RequireJS imports in the example above. A css! import only ever resolves undefined, so for cleanliness, we place it at the end of the list of imports. Otherwise, the RequireJS function would require a dummy parameter like cssUnused, which would add clutter. Feel free to use whatever import ordering scheme makes sense for you.)

myFirstModule.css

.MyFirstWidget {
  background-color: #E8E8E8;
  padding: 5px;
}

.MyFirstWidget > div {
  padding: 5px;
}

BCssResource

As of Niagara 4.13, in your BIJavaScript class representing your Widget, you can also declare a BCssResource dependency. This will tell the framework that the CSS file is used by your Widgets, so you don't have to remember the css! import. See the UI Changelog for details.

Next

See our Making your Widget Dashboardable tutorial!