halogen-view

1.0.4 • Public • Published

Halogen View

Build Status

Installing

$ npm install --save halogen-view

Running tests

Once:

$ npm install -g grunt-cli browserify

Once after cloning repo

$ npm install

Running tests

$ npm test

tl;dr

Push style template system for Halogen (and probably Backbone) models, allowing strict model/view separation.

You get 'if', 'if-not', 'hal-trigger', 'hal-click-toggle', 'hal-with' and 'hal-bind' as the only custom attributes you need to learn See paper on this subject.

Features

  • Logicless moustache-eseque templates for attributes and innertext.
  • Define your own custom helpers to do advanced string processing
  • Hypermedia extensions: Automatically insert href attributes for recognised rels.
  • 'hal-trigger' custom attribute to trigger Halogen events on a model
  • 'if' custom attribute to conditionally display elements.
  • 'hal-bind' custom attribute to link an input to a model attribute
  • 'hal-with' to change scope of a template and render out collections (partials, in effect)
  • API for adding additional attributes for when you HAVE to touch the DOM.

Example

HTML in your page:

<div if="getting-name" id="some-view" class="{{type}}">
  <p>Hello, {{name}}</p>
  <label>Enter your name: <input hal-bind="name"></label>
  <div class="description">{{strip(description)}}</div>
  <a rel="some-rel"> Some link </a>
  <a rel="self" class="{{clicked}}" hal-trigger="special-link-clicked">A link to myself</a>
  <ul hal-with="noodle-flavours">
    <li class="flavour {{className}}"><a rel="self">{{flavour}}</a></li>
  </ul>
</div>

JSON HAL document on the server

{
  "_links" : {
    "self" : {
      "href" : "/a-link-to-me"
    },
    "some-rel" : {
      "href" : "/some-link"
    }
  },
  "description" : "This is __very__ exciting",
  "type" : "testing-thing",
  "_embedded" : {
    "noodle-flavours" : [
      {
        "_links" : {
          "self" : {
            "href" : "/flavours/chicken"
          }
        },
        "flavour" : "Chickenesque",
        "classification" : "edible"
      },
      {
        "_links" : {
          "self" : {
            "href" : "/flavours/beef"
          }
        },
        "flavour" : "Spicy Beef substitute",
        "classification" : "toxic"
      },
      {
        "_links" : {
          "self" : {
            "href" : "/flavours/curry"
          }
        },
        "flavour" : "Curry. Just Curry.",
        "classification" : "edible"
      }
    ]
  }
  "name" : ""
}

Presume that we've loaded this JSON into a HalogenModel instance..

 
var HalogenView = require('halogen-view').HalogenView;
 
// we want to register a helper called 'strip'. This will be available to all Views in the system.
 
require('halogen-view').registerHelper('strip', function( str ){
  return markdownStripper( str )
})
 
// now we can create our view instance.
new HalogenView({
 
  // the model...
  model : myHypermediaDocument,
 
  // our view root
  el : '#some-view'
});
 
// set our editing flag to true so that we can see our html
myHypermediaDocument.set('editing', true);
 
// bind to a Halogen event that'll trigger when the user
// clicks on the particular link.
myHypermediaDocument.on('special-link-clicked', function( model ){
  model.set('clicked', 'clicked');
})
 

As soon as the initial processing is done, our DOM has been transformed.

Some things to note:

  • The collection of flavours has been expanded
  • Each flavour has automatically had its own href added to the link because of the rel='self'
  • Our link to rel='some-rel' has had its href added as well.
  • Our 'strip' helper has removed the markdown from 'description'.
<div id="some-view" class="testing-thing">
  <p>Hello, </p>
  <label>Enter your name: <input hal-bind="name"></label>
  <div class="description">This is very exciting</div>
  <a href="/some-link" rel="some-rel"> Some link </a>
  <a href="/a-link-to-me" rel="self" class="">A link to myself</a>
  <ul>
    <li class="flavour edible"><a href="/flavour/chicken" rel="self">Chickenesque</a></li>
    <li class="flavour toxic"><a href="/flavour/beef" rel="self">Spicy Beef substitute</a></li>
    <li class="flavour edible"><a href="/flavour/curry" rel="self">Curry. Just Curry.</a></li>
    <li>
  </ul>
</div>

If you happen to do this in your code....

myHypermediaDocument.set('type', 'sure-hope-this-works')

Then the page automatically updates to...

<div id="some-view" class="sure-hope-this-works">

And if you happen to click on A link to myself, the Halogen event fires, updates the model and that results in..

  <a href="/a-link-to-me" rel="self" class="clicked">A link to myself</a>
</div>

And if you type something into the the 'Enter your name box'

<p>Hello, something</p>

And if you do

myHypermediaDocument.set('editing', false);

Then the element gets hidden.

Halogen View has a number of dependencies which are installed at the same time. These are:

  • Underscore
  • component/dom
  • Parts of Backbone

Note that unlike Backbone View this does not have a dependency on jQuery. It does use a tiny standalone dom manipulation component called Dom instead.

Module API

require('halogen-view').registerHelper(name, fn)

Register a helper function for use inside templates. It becomes globally available to all views.

Example:

  require('halogen-view').registerHelper('shout', function( str ){
 
   return str.toUpperCase();
 
  });
  new HalogenView({ model: new HalogenModel({ name : "squirrel"}), el : dom('#namebox')});

The template calls the helper...

<p id="namebox">Hello {{shout(name)}}</p>

Which produces

<p>Hello SQUIRREL</p>

require('halogen-view').registerAttributeHandler(name, fn)

Register a custom attribute handler for extending the capabilities of View. More on this below.

require('halogen-view').HalogenView

Your reference to the HalogenView prototype.

var HalogenView = require('halogen-view').HalogenView;
 
new HalogenView({
    model : model,
    el : el,
    initialised : function(){
 
      // i get called after it's initialised for the first time.
 
    }
});

or

new HalogenView().create(el, model);

HalogenView Instance API

.on( event, callback )

HalogenView instances are Backbone event emitters. There are three events emitted currently: initialised, updated and delegate-fired.

The callbacks are passed a dom object, which is the view HTML and the model. For updated and delegate fired, information about what has changed is also added.

The philosphy behind these events is that they're useful for running integration tests, keeping a track on your application's state directly.

view.on('initialised', function(el, model){
  // I want to set some stuff in the model that's specific to the view but isn't in the Hypermedia
  // that came from the server.
  model.set('status', 'active');
 
})
view.on('updated', function(el, model, event){
  // event is 'change:someproperty' or something like that
 
  if (event==="change:status"){
    // do some horrible philosphy breaking stuff here
  }
 
})
view.on('delegate-fired', function(el, model, selector){
 
  if (selector==="click a.status"){
    logger('a.status clicked');
  }
 
})

.create( dom, HalogenModel )

If you want to postpone the view initialising, you can manually triggered this by invoking HalogenView without a model and el and then calling .create(). Pass it either a CSS selector or a dom List object along with the model and this then binds the model to the view.

.addDelegate(obj | name, fn)

If you're using the .create() method, you can manually set up actual DOM event delegates, although this... probably isn't wise.

new HalogenView({
  delegates : {
    'click .icon' : function( event ){
      // do something here. Scope is the model.
    }
  },
  model : model,
  el: el
})

is equivilant to

new HalogenView()
  .addDelegate('click .icon', function(event){
    // do something here
  })
  .create(el, model)

Halogen HTML Attributes

Halogen attributes can be added to the HTML, and allow for additional functionality not provided in the logicless attribute/innerText templates.

if="attribute"

Given the truthiness of the model attribute, it will conditionally display the element.

<p if="organisation">{{organisation}}</p>

This is as complex as the logic gets. How do I do an 'else' or an 'or' or an 'and' I hear you cry. Anything more complex than this is a job for code. It's what code is good at. The philosophy is that you do your difficult logic stuff in your code.

hal-with="attribute"

Changes the scope for the innerHTML to the selected model or collection. In effect the nested elements become a partial.

This HTML...

<div hal-with="nested-model">
  <p>{{greeting}}</p>
</div>

... is equivilant to

<div><p>{{nested-model.greeting}}</p></div>

... except when you use hal-with for a model you create a subview and any change events that fire show only the sub-view and the sub-model.

Slightly more useful than this is the ability to iterate through collections with hal-with

<ul hal-with="nested-collection">
  <li>{{name}}</li>
</ul>

... this then automatically clones the li tag for every model inside the collection.

hal-trigger="Halogen-event"

On clicking an element with the hal-trigger attribute, a subscribeable Halogen event is fired. The handler is passed three parameters - the originating model, the name of the signal and a function to cancel any default DOM events.

This solves a particular problem of being able to access individual models within collections without doing horrible things to the DOM.

A futher example:

Our model contains...

{
  filters : [
    {
      name : "Filter one",
      active : true
    },
    {
      name : "Filter two",
      active : false
    }
  ]
}

And our view makes a new li for each filter. The scope of each li is the individual model in the collection.

<ul hal-with="filters">
  <li class="if(model.get('active'), 'active')" hal-trigger="filter-changed">{{name}}</li>
</ul>

Which means when that li is clicked, the 'filters-changed' event fires on the 'filters' object (in backbone style that's filters-changed:filters), and the first parameter is the individual filter.

model.on('filters-changed:filters', function( filter, signal, cancelDefault ){
 
  // call cancelDefault() to prevent the default DOM event from firing.
 
  filter.set('active', true);
})

hal-click-toggle="model-attribute"

The most common use case for hal-trigger is actually just toggling a flag on or off, so this custom attribute automates this for you.

<section>
  <section if-not="editing">
    <button hal-click-toggle="editing">Edit</button>
    <p>Hello {{Name}}</p>
  </section>
  <section if="editing">
    <button hal-click-toggle="editing">View</button>
    <p>Enter your name:<input hal-bind="Name"></p>
  </section>
</section>

That's really all there is to it. You can, of course, bind to the change event and do somethign else...

app.on('change:editing', function(){
  // editing has changed!
})

hal-bind

This attribute allows two-way binding to form inputs to allow an easy way to let your users interact with your model.

<body class="{{theme}}">
  <select hal-bind="theme">
    <option value="default">Default</option>
    <option value="dark">Dark</option>
    <option value="light">Light</option>
  </select>
</body>

When used with a model..

{
  theme : "default"
}

...results in the class on the body tag being automatically updated when the user changes the select. Etc.

Adding your own custom attributes

Because Halogen View enforces a strict separation of model and view, your applications shouldn't be touching the DOM at all. However, sometimes, you do in fact need to touch the DOM. When you do, the idea is that you use your own custom attributes. Luckily Halogen View exposes an API for this.

require('halogen-view').registerAttributeHandler( attributeName, fn )

require('halogen-view').use( attributeHandlers : { attributeName : fn })

fn is called when HalogenView finds an element with your attribute. When called, it is passed the element, the value of the attribute as arguments and a 'cancel' function. The scope is the instance of HalogenView itself, meaning you can use this.model and this.el (this may not be true forever)

The cancel function should be called if you do not wish the View to continue processing the node (i.e, recurse into the childNodes etc).

Here's a non-disruptive non-cancelled example. We want a link to switch between .on and .off whenever it's clicked..

<a x-switch="status:off|on" class="{{status}}" href="#"></a>
// create a model
var model = new HalogenModel({
  status : ""
});
// register an attribute handler
require('halogen-view').registerAttributeHandler('x-switch', function(node, propertyValue, cancel){
 
    var self = this; // hey, 'this' is the HalogenView.
 
    // it's a custom attribute so you need to do your own 
    // parsing. You get 'status:on|off' passed to you.
    var parts = propertyValue.split(":");
    var prop = parts[0];
    var options = parts[1].split("|");
 
    // we're in the HalogenView scope so this works... 
    this.model.set(prop, options[1]);
 
    // Create a click handler for this element..
    dom(node).on('click', function(e){
 
      e.preventDefault();
 
      // we tweak the model here.. 
      if (self.model.get(prop) === options[0]){
        self.model.set(prop, options[1])
      } else {
        self.model.set(prop, options[0])
      }
 
    })
 
    // we don't call cancel here, so the childNodes will be processed as normal
 
  });
// create a view
new HalogenView({ model: model, el : html});

A disruptive 'cancelling' example: Creating a new instance of HalogenView with a different model to process the element and all its children.

This is the parent Hypermedia document. Note that it contains a rel some-rel which points to /some-other-document.

{
  "_links" : {
    "self" : {
      "href" : "/some-document"
    },
    "some-rel" : {
      "href" : "/some-other-document"
    }
  },
  "greeting" : "Welcome to the magic world of Hypermedia"
}

And this is the JSON for /some-other-document

{
  "_links" : {
    "self" : {
      "href" : "/some-other-document"
    },
    "other-thing" : {
      "href" : "/some-document"
    }
  },
  "greeting" : "Woooo!"
}

Our HTML. We want to manually embed /some-other-document into our page. We don't use the href, only the rel.

<div>
<p>{{greeting}}</p>
<div x-embed="some-rel"><p>{{greeting}}</p></div>
</div>

Now we add our custom attribute handler...

// add attribute handler
require('halogen-view').registerAttributeHandler('x-embed', function(node, propertyValue, cancel){
 
    // remove the attribute so that when we create a subview
    // we don't end up back inside this handler.
    node.removeAttribute('x-embed');
 
    // Halogen Models have a special helper method for looking
    // up the hrefs of rels.
    var uri = this.model.rel(propertyValue);
 
    // wrap our naked element in a dom object.
    var root = dom(node);
 
    // load the model...
    request.get(uri).set('Accept', 'application/json+hal').end( function(err, doc){
 
      if(!err){
 
        // create a new view, passing it our wrapped element and a new Halogen Model.
        new HalogenView()
          .create( root, new HalogenModel( doc ) );
 
      }
 
    });
 
    // and we don't want the original View to continue processing this node
    // and the node's children, so we...
    cancel();
 
  });
 
// create a view
new HalogenView({model : someModel, el : myElement });
 

WHich should, after everything's loaded, result in..

<div>
<p>Welcome to the magic world of Hypermedia</p>
<div x-embed="some-rel"><p>Woooo!</p></div>
</div>

As these two examples should demonstrate, using the custom attribute handler API is fairly powerful, largely unopinionated... and very very easy to abuse.

Logicless Template rules

It looks like moustache templating but it's not. It supports referencing model attributes, calling custom helpers (which are passed the referenced model attribute) and... if you really really must... you can just send in arbitrary javascript so long as it's inside a call to a custom helper.

Built ins:

  • {{property}} automatically becomes model.get("property")
  • {{get(property)}} for when you absolutely want everyone to know there's some backbone happening
  • {{url()}} gets the _links.self.href
  • {{rel('some-rel')}} gets a specific rel
  • {{expresion(1 + 2 + model.get('current-value'))}} - expression helper lets you add arbitrary javascript. Note the use of model.get to access data in the model is required in this situation.

Custom helpers:

  • {{myHelper(property)}} passes model.get('property') to your custom handler

Won't work:

  • {{1 + 2}}

Updates

  • 10th August 2014: Removed 'replaceWholeText' in templated renderer, restoring cross-browser compatibility

License

MIT

Readme

Keywords

none

Package Sidebar

Install

npm i halogen-view

Weekly Downloads

3

Version

1.0.4

License

MIT

Last publish

Collaborators

  • charlottegore