Giter Site home page Giter Site logo

ampersandjs / ampersand-view Goto Github PK

View Code? Open in Web Editor NEW
92.0 12.0 39.0 730 KB

A smart base view for Backbone apps, to make it easy to bind collections and properties to the DOM.

Home Page: http://ampersandjs.com

License: MIT License

JavaScript 100.00%
ampersand

ampersand-view's Introduction

ampersand-view

Lead Maintainer: Rick Butler

A set of common helpers and conventions for using as a base view for ampersand.js apps.

What does it do?

  1. Gives you a proven pattern for managing/binding the contents of an element.
  2. Simple declarative property/template bindings without needing to include a template engine that does it for you. This keeps your logic out of your templates and lets you use a string of HTML as a fully dynamic template or just a simple function that returns an HTML string.
  3. The view's base element is replaced (or created) during a render. So, rather than having to specify tag type and attributes in javascript in the view definition you can just include that in your template like everything else.
  4. A way to render a collection of models within an element in the view, each with their own view, preserving order, and doing proper cleanup when the main view is removed.
  5. A simple way to render sub-views that get cleaned up when the parent view is removed.

Part of the Ampersand.js toolkit for building clientside applications.

install

npm install ampersand-view

API Reference

Note that this is a fork of Backbone's view so most of the public methods/properties here still exist: http://backbonejs.org/#View.AmpersandView extends AmpersandState so it can have it's own props values for example and can be bound directly to the template without a backing model object.

extend AmpersandView.extend([properties])

Get started with views by creating a custom view class. Ampersand views have a sane default render function, which you don't necessarily have to override, but you probably will wish to specify a template, your declarative event handlers and your view bindings.

var PersonRowView = AmpersandView.extend({
    template: "<li> <span data-hook='name'></span> <span data-hook='age'></span> <a data-hook='edit'>edit</a> </li>",

    events: {
        "click [data-hook=edit]": "edit"
    },

    bindings: {
        "model.name": {
            type: 'text',
            hook: 'name'
        },

        "model.age": {
            type: 'text',
            hook: 'age'
        }
    },

    edit: function () {
        //...
    }
});

template AmpersandView.extend({ template: "<div><input></div>" })

The .template is a property for the view prototype. It should either be a string of HTML or a function that returns a string of HTML or a DOM element. It isn't required, but it is used as a default for calling renderWithTemplate.

The important thing to note is that the returned string/HTML should not have more than one root element. This is because the view code assumes that it has one and only one root element that becomes the .el property of the instantiated view.

For more information about creating, and compiling templates, read the templating guide.

autoRender AmpersandView.extend({ autoRender: true })

The .autoRender property lets you optionally specify that the view should just automatically render with all the defaults. This requires that you at minimum specify a template string or function.

By setting autoRender: true the view will simply call .renderWithTemplate for you (after your initialize method if present). So for simple views, if you've got a few bindings and a template your whole view could just be really declarative like this:

var AmpersandView = require('ampersand-view');


module.exports = AmpersandView.extend({
    autoRender: true,
    template: '<div><span id="username"></span></div>',
    bindings: {
        name: '#username'
    }
});

Note: if you are using a template function (and not a string) the template function will get called with a context argument that looks like this, giving you access to .model, .collection and any other props you have defined on the view from the template.

this.renderWithTemplate(this, this.template);

events AmpersandView.extend({ events: { /* ...events hash... */ } })

The events hash allows you to specify declarative callbacks for DOM events within the view. This is much clearer and less complex than calling el.addEventListener('click', ...) everywhere.

  • Events are written in the format {"event selector": "callback"}.
  • The callback may either be the name of a method on the view, or an actual function.
  • Omitting the selector causes the event to be bound to the view's root element (this.el).
  • The events property may also be defined as a function that returns an events hash, to make it easier to programmatically define your events, as well as inherit them from parent views.

Using the events hash has a number of benefits over manually binding events during the render call:

  • All attached callbacks are bound to the view before being handed off to the event handler, so when the callbacks are invoked, this continues to refer to the view object.
  • All event handlers are delegated to the view's root el, meaning elements changed when the view is updated don't need to be unbound and rebound.
  • All events handlers are cleanly removed when the view is removed.
var DocumentView = AmpersandView.extend({

  events: {
    //bind to a double click on the root element
    "dblclick"                : "open",

    //bind to a click on an element with both 'icon' and 'doc' classes
    "click .icon.doc"         : "select",

    "contextmenu .icon.doc"   : "showMenu",
    "click .show_notes"       : "toggleNotes",
    "click .title .lock"      : "editAccessLevel",
    "mouseover .title .date"  : "showTooltip"
  },

  open: function() {
    window.open(this.model.viewer_url);
  },

  select: function() {
    this.model.selected = true;
  },

  //...

});

Note that the events definition is not merged with the superclass definition. If you want to merge events from a superclass, you have to do it explicitly:

var SuperheroRowView = PersonRowView.extend({
  events: _.extend({}, PersonRowView.prototype.events, {
    'click [data-hook=edit-secret-identitiy]': 'editSecretIdentity'
  })
});

bindings

The bindings hash gives you a declarative way of specifying which elements in your view should be updated when the view's model is changed.

For a full reference of available binding types see the ampersand-dom-bindings section.

For example, with a model like this:

var Person = AmpersandModel.extend({
    props: {
        name: 'string',
        age: 'number',
        avatarURL: 'string'
    },
    session: {
        selected: 'boolean'
    }
});

and a template like this:

<!-- templates.person -->
<li>
  <img data-hook="avatar">
  <span data-hook="name"></span>
  age: <span data-hook="age"></span>
</li>

you might have a binding hash in your view like this:

var PersonView = AmpersandView.extend({
    templates: templates.person,

    bindings: {
        'model.name': {
            type: 'text',
            hook: 'name'
        },

        'model.age': '[data-hook=age]', //shorthand of the above

        'model.avatarURL': {
            type: 'attribute',
            name: 'src',
            hook: 'avatar'
        },

        //no selector, selects the root element
        'model.selected': {
            type: 'booleanClass',
            name: 'active' //class to toggle
        }
    }
});

Note that the bindings definition is not merged with the superclass definition. If you want to merge bindings from a superclass, you have to do it explicitly:

var SuperheroRowView = PersonRowView.extend({
  bindings: _.extend({}, PersonRowView.prototype.bindings, {
    'model.secretIdentity': '[data-hook=secret-identity]'
  })
});

el view.el

All rendered views have a single DOM node which they manage, which is acessible from the .el property on the view. Allowing you to insert it into the DOM from the parent context.

var view = new PersonView({ model: me });
view.render();

document.querySelector('#viewContainer').appendChild(view.el);

constructor new AmpersandView([options])

The default AmpersandView constructor accepts an optional options object, and:

  • Attaches the following options directly to the instantiated view, overriding the defaults: model, collection, el.
  • Sets up event bindings defined in the events hash.
  • Sets up the model bindings defined in the bindings hash.
  • Initializes any subviews defined in the subviews hash.
  • Calls initialize passing it the options hash.
  • Renders the view, if autoRender is true and a template is defined.

Typical use-cases for the options hash:

  • To initialize a view with an el already in the DOM, pass it as an option: new AmpersandView({ el: existingElement }).
  • To perform extra work when initializing a new view, override the initialize function in the extend call, rather than modifying the constructor, it's easier.

initialize new AmpersandView([options])

Called by the default view constructor after the view is initialized. Overwrite initialize in your views to perform some extra work when the view is initialized. Initially it's a noop:

var MyView = AmpersandView.extend({
    initialize: function (options) {
        console.log("The options are:", options);
    }
});

var view = new MyView({ foo: 'bar' });
//=> logs 'The options are: {foo: "bar"}'

If you want to extend the initialize function of a superclass instead of redefining it completely, you can explicitly call the initialize of the superclass at the right time:

var SuperheroRowView = PersonRowView.extend({
  initialize: function () {
    PersonRowView.prototype.initialize.apply(this, arguments);
    doSomeOtherStuffHere();
  })
});

render view.render()

Render is a part of the Ampersand View conventions. You can override the default view method when extending AmpersandView if you wish, but as part of the conventions, calling render should:

  • Create a this.el property if the view doesn't already have one, and populate it with your view template
  • or if the view already has a this.el attribute, render should either populate it with your view template, or create a new element and replace the existing this.el if it's in the DOM tree.
  • Not be a problem if it's called more than once.

The default render looks like this:

render: function () {
    this.renderWithTemplate(this);
    return this;
}

If you want to extend the render function of a superclass instead of redefining it completely, you can explicitly call the render of the superclass at the right time:

var SuperheroRowView = PersonRowView.extend({
  render: function () {
    PersonRowView.prototype.render.apply(this, arguments);
    doSomeOtherStuffHere();
  })
});

ampersand-view triggers a 'render' event for your convenience, too, if you want to set listeners for it. The 'render' and 'remove' events emitted by this module are merely convenience events, as you may listen solely to change:rendered in order to capture the render/remove events in just one listener.

renderCollection view.renderCollection(collection, ItemView, containerEl, [viewOptions])

  • collection {Backbone Collection} The instantiated collection we wish to render.
  • ItemView {View Constructor | Function} The view constructor that will be instantiated for each model in the collection or a function that will return an instance of a given constructor. options object is passed as a first argument to a function, which can be used to access options.model and determine which view should be instantiated. This view will be used with a reference to the model and collection and the item view's render method will be called with an object containing a reference to the containerElement as follows: .render({containerEl: << element >>}).
  • containerEl {Element | String} This can either be an actual DOM element or a CSS selector string such as .container. If a string is passed ampersand-view runs this.query('YOUR STRING') to try to grab the element that should contain the collection of views. If you don't supply a containerEl it will default to this.el.
  • viewOptions {Object} [optional] Additional options
    • viewOptions {Object} Options object that will get passed to the initialize method of the individual item views.
    • filter {Function} [optional] Function that will be used to determine if a model should be rendered in this collection view. It will get called with a model and you simply return true or false.
    • reverse {Boolean} [optional] Convenience for reversing order in which the items are rendered.

This method will maintain this collection within that container element. Including proper handling of add, remove, sort, reset, etc.

Also, when the parent view gets .remove()'ed any event handlers registered by the individual item views will be properly removed as well.

Each item view will only be .render()'ed once (unless you change that within the item view itself).

The collection view instance will be returned from the function.

Example:

// some views for individual items in the collection
var ItemView = AmpersandView.extend({ ... });
var AlternativeItemView = AmpersandView.extend({ ... });

// the main view
var MainView = AmpersandView.extend({
    template: '<section class="page"><ul class="itemContainer"></ul></section>',
    render: function (opts) {
        // render our template as usual
        this.renderWithTemplate(this);

        // call renderCollection with these arguments:
        // 1. collection
        // 2. which view to use for each item in the list
        // 3. which element within this view to use as the container
        // 4. options object (not required):
        //      {
        //          // function used to determine if model should be included
        //          filter: function (model) {},
        //          // boolean to specify reverse rendering order
        //          reverse: false,
        //          // view options object (just gets passed to item view's `initialize` method)
        //          viewOptions: {}
        //      }
        // returns the collection view instance
        var collectionView = this.renderCollection(this.collection, ItemView, this.el.querySelector('.itemContainer'), opts);
        return this;
    }
});

// alternative main view
var AlternativeMainView = AmpersandView.extend({
    template: '<section class="sidebar"><ul class="itemContainer"></ul></section>',
    render: function (opts) {
        this.renderWithTemplate(this);
        this.renderCollection(this.collection, function (options) {
            if (options.model.isAlternative) {
                return new AlternativeItemView(options);
            }

            return new ItemView(options);
        }, this.el.querySelector('.itemContainer'), opts);
        return this;
    }
});

renderWithTemplate view.renderWithTemplate([context], [template])

  • context {Object | null} [optional] The context that will be passed to the template function, usually it will be passed the view itself, so that .model, .collection etc are available.
  • template {Function | String} [optional] A function that returns HTML or a string of HTML.

This is shortcut for the default rendering you're going to do in most every render method, which is: use the template property of the view to replace this.el of the view and re-register all handlers from the event hash and any other binding as described above.

var view = AmpersandView.extend({
    template: '<li><a></a></li>',
    bindings: {
        'name': 'a'
    },
    events: {
        'click a': 'handleLinkClick'
    },
    render: function () {
        // this does everything
        // 1. renders template
        // 2. registers delegated click handler
        // 3. inserts and binds the 'name' property
        //    of the view's `this.model` to the <a> tag.
        this.renderWithTemplate();
    }
});

query view.query('.classname')

Runs a querySelector scoped within the view's current element (view.el), returning the first matching element in the dom-tree.

notes:

  • It will also match against the root element.
  • It will return the root element if you pass '' as the selector.
  • If no match is found it returns undefined
var view = AmpersandView.extend({
    template: '<li><img class="avatar" src=""></li>',
    render: function () {
        this.renderWithTemplate(this);

        // cache an element for easy reference by other methods
        this.imgEl = this.query(".avatar");

        return this;
    }
});

queryByHook view.queryByHook('hookname')

A convenience method for retrieving an element from the current view by it's data-hook attribute. Using this approach is a nice way to separate javascript view hooks/bindings from class/id selectors that are being used by CSS.

notes:

  • It also works if you're using multiple space-separated hooks. So something like <img data-hook="avatar user-image"/> would still match for queryByHook('avatar').
  • It simply uses .query() under the hood. So .queryByHook('avatar') is equivalent to .query('[data-hook~=avatar]')
  • It will also match to root elements.
  • If no match is found it returns undefined.
var view = AmpersandView.extend({
    template: '<li><img class='avatar-rounded' data-hook="avatar" src=""></li>',
    render: function () {
        this.renderWithTemplate(this);

        // cache an element for easy reference by other methods
        this.imgEl = this.queryByHook('avatar');

        return this;
    }
});

queryAll view.queryAll('.classname')

Runs a querySelectorAll scoped within the view's current element (view.el), returning an array of all matching elements in the dom-tree.

notes:

  • It will also include the root element if it matches the selector.
  • It returns a real Array not a DOM collection.

queryAllByHook view.queryAllByHook('hookname')

Uses queryAll method with a given data-hook attribute to retrieve all matching elements scoped within the view's current element (view.el), returning an array of all matching elements in the dom-tree or an empty array if no results has been found.

cacheElements view.cacheElements(hash)

A shortcut for adding reference to specific elements within your view for access later. This is avoids excessive DOM queries and makes it easier to update your view if your template changes. It returns this.

In your render method. Use it like so:

render: function () {
  this.renderWithTemplate(this);

  this.cacheElements({
    pages: '#pages',
    chat: '#teamChat',
    nav: 'nav#views ul',
    me: '#me',
    cheatSheet: '#cheatSheet',
    omniBox: '[data-hook=omnibox]'
  });

  return this;
}

Then later you can access elements by reference like so: this.pages, or this.chat.

listenToAndRun view.listenToAndRun(object, eventsString, callback)

Shortcut for registering a listener for a model and also triggering it right away.

remove view.remove()

Removes a view from the DOM, and calls stopListening to remove any bound events that the view has listenTo'd. This method also triggers a remove event on the view, allowing for listeners (or the view itself) to listen to it and do some action, like cleanup some other resources being used. The view will trigger the remove event if remove() is overridden.

initialize : function() {
  this.listenTo(this,'remove',this.cleanup);
  // OR this, either statements will call 'cleanup' when `remove` is called
  this.once('remove',this.cleanup, this);
},

cleanup : function(){
  // do cleanup
}

registerSubview view.registerSubview(viewInstance)

  • viewInstance {Object} Any object with a "remove" method, typically an instantiated view. But doesn't have to be, it can be anything with a remove method. The remove method doesn't have to actually remove itself from the DOM (since the parent view is being removed anyway), it is generally just used for unregistering any handler that the subview sets up.

This method will:

  1. store a reference to the subview for cleanup when remove() is called.
  2. add a reference to itself at subview.parent
  3. return the subview

renderSubview view.renderSubview(viewInstance, containerEl)

  • viewInstance {Object} Any object with a .remove(), .render() and an .el property that is the DOM element for that view. Typically this is just an instantiated view.
  • containerEl {Element | String} This can either be an actual DOM element or a CSS selector string such as .container. If a string is passed ampersand-view runs this.query('YOUR STRING') to try to grab the element that should contain the sub view. If you don't supply a containerEl it will default to this.el.

This method is just sugar for the common use case of instantiating a view and putting in an element within the parent.

It will:

  1. fetch your container (if you gave it a selector string)
  2. register your subview so it gets cleaned up if parent is removed and so this.parent will be available when your subview's render method gets called
  3. call the subview's render() method
  4. append it to the container (or the parent view's el if no container given)
  5. return the subview
var view = AmpersandView.extend({
    template: '<li><div class="container"></div></li>',
    render: function () {
        this.renderWithTemplate();

        //...

        var model = this.model;
        this.renderSubview(new SubView({
            model: model
        }), '.container');

        //...

    }
});

subviews view.subviews

You can declare subviews that you want to render within a view, much like you would bindings. Useful for cases where the data you need for a subview may not be available on first render. Also, simplifies cases where you have lots of subviews.

When the parent view is removed the remove method of all subviews will be called as well.

You declare them as follows:

var AmpersandView = require('ampersand-view');
var CollectionRenderer = require('ampersand-collection-view');
var ViewSwitcher = require('ampersand-view-switcher');


module.exports = AmpersandView.extend({
    template: '<div><div></div><ul data-hook="collection-container"></ul></div>',
    subviews: {
        myStuff: {
            selector: '[data-hook=collection-container]',
            waitFor: 'model.stuffCollection',
            prepareView: function (el) {
                return new CollectionRenderer({
                    el: el,
                    collection: this.model.stuffCollection
                });
            }
        },
        tab: {
            hook: 'switcher',
            constructor: ViewSwitcher
        }
    }
});

subview declarations consist of:

  • selector {String} Selector that describes the element within the view that should hold the subview.
  • hook {String} Alternate method for specifying a container element using its data-hook attribute. Equivalent to selector: '[data-hook=some-hook]'.
  • constructor {ViewConstructor} Any view conventions compliant view constructor. It will be initialized with {el: [Element grabbed from selector], parent: [reference to parent view instance]}. So if you don't need to do any custom setup, you can just provide the constructor.
  • waitFor {String} String specifying they "key-path" (i.e. 'model.property') of the view that must be "truthy" before it should consider the subview ready.
  • prepareView {Function} Function that will be called once any waitFor condition is met. It will be called with the this context of the parent view and with the element that matches the selector as the argument. It should return an instantiated view instance.

delegateEvents view.delegateEvents([events])

Creates delegated DOM event handlers for view elements on this.el. If events is omitted, will use the events property on the view.

Generally you won't need to call delegateEvents yourself, if you define an event hash when extending AmpersandView, delegateEvents will be called for you when the view is initialize.

Events is a hash of {"event selector": "callback"}*

Will unbind existing events by calling undelegateEvents before binding new ones when called. Allowing you to switch events for different view contexts, or different views bound to the same element.

{
  'mousedown .title':  'edit',
  'click .button':     'save',
  'click .open':       function (e) { ... }
}

undelegateEvents view.undelegateEvents()

Clears all callbacks previously bound to the view with delegateEvents. You usually don't need to use this, but may wish to if you have multiple views attached to the same DOM element.

changelog

  • 10.0.3 - memory leak in _subviews array, it was not being cleared in _downsertBindings (#182)
  • 10.0.1 - bump deps
  • 10.0.0
    • bump to ampersand-state 5.x
    • reduce bundle size via lodash upgrade and require methodology
  • 9.0.0
    • support render-remove-render view instance lifecycle. previously bindings were trashed for good on remove()
    • subviews declared in the subviews hash by default append the subview-el into its target, vs replace its target
  • 8.0.0 Improve rendered property behavior. rendered now set after calling render()/remove() fns, vs. old strategy which simply checked for view.el
  • 7.0.0 Replacing use of role in lieu of data-hook for accessibility reasons discussed here
  • [insert period of poor changelog management here], this will not happen again now that ampersand is public.
  • 1.6.3 diff - Move throw statment for too many root elements inside non <body> case.
  • 1.6.2 diff - Make getByRole work even if role attribute is on the root element. Throws an error if your view template contains more than one root element.
  • 1.6.1 diff - Make sure renderSubview registers the subview first, so it has a .parent before it calls .render() on the subview.
  • 1.6.0 diff - Adding getByRole method
  • 1.5.0 - Adding bower.json, adding missing dev dependencies, other small bugfixes.
  • 1.4.1 - Removing elements without using jQuery's .empty() in renderCollection. (fixes: https://github.com/HenrikJoreteg/ampersand-view/issues/13)
  • 1.4.0 - Adding parent reference to subviews registered via registerSubview

test coverage?

Why yes! So glad you asked :)

  • Run npm test to run the tests in a headless phantom browser.
  • Run npm start to start a webserver with the test harness, and then visit http://localhost:3000 to open and run the tests in your browser of choice.

like this?

Follow @HenrikJoreteg on twitter and check out my recently released book: human javascript which includes a full explanation of this as well as a whole bunch of other stuff for building awesome single page apps.

license

MIT

ampersand-view's People

Contributors

arb avatar aredridel avatar bear avatar bryanspears avatar cdaringe avatar dhritzkiv avatar dminkovsky avatar drewloomer avatar greenkeeperio-bot avatar henrikjoreteg avatar herkyl avatar ike avatar janpaul123 avatar jrmyio avatar kahlil avatar kamilogorek avatar kimpeeters avatar latentflip avatar lukekarrys avatar mmacaula avatar nathanstitt avatar pgilad avatar qmx avatar remko avatar rickbutler avatar ruiramos avatar spencerbyw avatar tnguyen14 avatar wraithgar avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

ampersand-view's Issues

renderSubview does not register `parent` property on subview

In the renderSubview method, registerSubview is called before subview.render, which means at this point the subview.el is undefined, which causes the subview to not have a reference to its parent (https://github.com/AmpersandJS/ampersand-view/blob/master/ampersand-view.js#L206).

Is this intentional?

Currently, I have to work around this by rendering the subview first, append it to the parent el, then call registerSubview. I think that kinda defeats the purpose of the renderSubview method.

I am wondering if I am doing something wrong, and if anyone else runs into a similar problem.

Complex bindings error when created according to AmpersandJS.com example

The bug

The example shows:

// If you really need to, you can even bind the same attribute to different
// types of things with different options. If "superActive" was a string, the following would put
// the text value of it, inside `.userName` and add it as a class on the `li`.
superActive: [
    // the *only* restriction here is that if you pass an array of binding
    // declarations for a single property, each sub-item must also be an
    // array.
    ['.userName'],
    ['li', 'class'],
    // you can even get crazy... this would bind both
    // data attributes to both the li and .username elements
    ['li, .username', 'data-attribute1 data-attribute2'],
]     

Following the example throws 'undefined is not a function' when processing the first definition because the code for non-string defs expects a second member to always exists and be a string. The error occurs at ampersand-view.js#L282 when the code calls attr.split(' ').

Proposed fix

Change ampersand-view.js#L280 to read:

attr = definition[1] || 'text';

An idea: backbone deprecation

So this is just an idea, which we may/may not want to do.

I just ran into an issue where I was trying to do:

var view = new MyView({ model: '...' });
$('.selector').append(view.render().$el);

Now this silently fails because ampersand-view doesn't have a $el property.

This made me wonder if we should/could add depracation warnings for obvious differences between backbone and ampersand-*. Here we could have a Object.defineProperty#get that logged a message about it not being supported in ampersand and to just use .el.

Anyway, just a thought that crossed my mind about how to make the transition from backbone/human model easier for people.

Handle view.query() being invoked when view.el is undefined

Currently, if view.el is undefined, a call to view.query() will fail with a Uncaught TypeError: Illegal invocation inside the matches-selector package. It would be nicer to have a check in the query method that handles this condition.

query: function (selector) {
        if (!selector) return this.el;
        if (typeof selector === 'string') {
            if (matches(this.el, selector)) return this.el;  // this causes TypeError when this.el is undefined
            return this.el.querySelector(selector) || undefined;
        }
        return selector;
    }

You have a guard for this condition in the queryAll method, just not in the query method.

queryAll: function (selector) {
        var res = [];
        if (!this.el) return res;  // guards against condition where this.el === undefined
        if (selector === '') return [this.el];
        if (matches(this.el, selector)) res.push(this.el);
        return res.concat(Array.prototype.slice.call(this.el.querySelectorAll(selector)));
    },

Bubbling up events from subview to parentview

In my current project I ran into the need to listen to an event from a child view at the parent view level.

I had to create a hack as outlined in AmpersandJS/ampersand-state#77. I'm copying it below here:

// base.js
module.exports = State.extend({
    // copy ampersand-state bubble event to support custom event
    _bubbleEvent: function (propertyName) {
        return _.bind(function (name, model, newValue) {
            if (/^change:/.test(name)) {
                this.trigger('change:' + propertyName + '.' + name.split(':')[1], model, newValue);
            } else if (name === 'change') {
                this.trigger('change', this);
            } else {
                this.trigger(name, model, newValue);
            }
        }, this);
    }
});
// parentView.js
    render: function () {
        this.renderWithTemplate(this);
        this.child = new ChildView();
        this.renderSubview(this.child, this.el);
        this.listenTo(this.child, 'all', this._bubbleEvent('child'));
        return this;
    }

It would be nice to have this kind of event bubbling/ listening to happen automatically on the registerSubview function?

Notify parent view that child views have been rendered

With an ampersand view that has nested child views, it's child views are rendered with .renderCollection, everything works as expected.

How to notify the parent view that all its child views have completed rendering and appended the result to the DOM?
eg the parent view when all its child views have been rendered, it needs to initialize a jQuery plugin that access elements from the child views?

Subview replaces container rather than become child

The docs state:

container {String} Selector that describes the element within the view that should hold the subview
To me, that indicates that the container will remain, but have a new child once the subview is rendered. But this is not happening. Instead, the action() function in _parseSubview() calls render() which calls renderWithTemplate(), which then replaces the container with the subview:

var parent = this.el && this.el.parentNode;
if (parent) parent.replaceChild(newDom, this.el);

Here's a sample view that illustrates the issue:

var View = require('ampersand-view');
module.exports = View.extend({
    pageTitle: 'tester',
    template:[
        '<div class="root">',
            '<div class="one">',
                '<div class="two"></div>',
            '</div>',
        '</div>'
    ].join(''),
    subviews: {
        whoWeAre: {
            container: '.two',
            constructor: View.extend({
                template: '<p>Mike</p>'
            })
        },
        whatWeDo: {
            container: '.two',
            constructor: View.extend({
                template: '<p>Write code</p>'
            })
        }
    }
});

The intent is to build a page with a series of subviews that all get put into the same container. But the resulting html is not what one would expect:

<div class="root active">
  <div class="one">
    <p>Mike</p>
  </div>
</div>

Rather than my container div still being there with two children, instead only the first child exists, and the second child does not get rendered because it can't find its parent.

I think the renderSubview() function has the right idea - it uses appendChild rather than replaceChild. But re-routing that action() function to use renderSubview didn't work for me because it eventually calls renderWithTemplate - and since it can find a parent it calls replaceChild.

View Template Input Binding

Sorry if this is the wrong &-repo to ask this but I've been doing some searching and couldn't find anything to answer my question. I've just started getting familiar with Ampersand. I have the following in my view:

test: '123456789',
binding: {
    'test':{
        type: 'attribute',
        role: 'test_input',
        name: 'value'
    }
}

This sets up the binding correctly for my 'test' property to my templates input[role=test_input] element. When I navigate to the view I can see that value in the input.

My question: Is the binding set up both ways? I have tried changing the text and then checking the current value of the property using a button and console.log() and the value never changes to reflect what was typed into the input. The only way I have been able to accomplish the two-way binding (without having to use $ to fetch the value on a save button click) is to add the following to my events hash:

'change [role=test_input]': 'updateTest'

I then modify the property's value within updateTest(). Is there a better Ampersand way of doing this? I am coming from an Emberjs background so I could be easily doing something wrong. I am also using domthing for my template engine if that matters at all.

Emitting events for rendering/inserting

This is a feature request for emitting events when rendering.

My use case is for renderCollection(), as renderCollection can render a collection's existing state, as well as changes to the collection's state in the future. It would be nice to have a render event emitted when a round of rendering takes place, and/or an inserted event each time a new "ItemView" is rendered.

This would be handy for things like Masonry, which would need to have the newly rendered elements added to its instance in order to lay them out properly.

i.e.:

render: {
    this.renderWithTemplate(this);
    var holderElm = this.queryByHook('collection');
    var collectionView = this.renderCollection(this.collection, itemVIew, holderElm);
    var masonry;
    collectionView.on('insert', function(insertedItemView) {
        masonry.addItems(insertedItemView.el);
        masonry.layout();
    });
    masonry = new Masonry(holderElm);
}

Shorthands for subview declaration

Wouldn't it be great if you could do like this when you declared subviews?

    subviews: {
        status: {
            container: '[role=status]',
            constructor: StatusView,
            // No model is set so it defaults to its parent model.
        }
    }
    subviews: {
        user: {
            container: '[role=user]',
            constructor: UserView,
            model: 'model.user' // Will set model to model.user and automatically wait for it.
        },
    }
    subviews: {
        todos: {
            container: '[role=todos]',
            constructor: TodoView,
            collection: 'model.todos' // Will set collection to model.todos and automatically wait for it.
        }
    }

Right now it looks like this in my code.

    subviews: {
       status: {
            container: '[role=status]',
            waitFor: 'model',
            prepareView: function (el) {
                return new StatusView({
                    el: el,
                    model: this.model
                });
            }
        },
        user: {
            container: '[role=user]',
            waitFor: 'model.user',
            prepareView: function (el) {
                return new UserView({
                    el: el,
                    model: this.model.user
                });
            }
        },
        todos: {
            container: '[role=todos]',
            constructor: TodoView,
            waitFor: 'model.todos',
            prepareView: function (el) {
                return new TodoView({
                    el: el,
                    collection: this.model.todos
                });
            }
        }
    }

I can implement it, but first I would like to know if I missing something and if it is a good idea. What are the downsides to auto assigning parent model as model? What are the downsides to the two new shorthands?

Force trigger bindings

Is it possible to trigger all bindings on an object. Some of the bindings get messed up after I resize the window (because they set the width and height of elements).

Events not applied for rendered view + delegateEvents() throws an error.

When a view is already rendered and an element is passed to a view instance, the events hash is NOT applied, is this intended?

Also, when you call this.delegateEvents(); manually an error is thrown because this.eventManager does not exist yet (as this is only created in _handleElementChange()).

As a work around I'm currently using this.trigger("change:el");

Namespace collision warnings

When registering things like subviews, the names in the hash are blindly applied to this, which could collide with things like props or session variables, it would be kind to the dev if there were some sort of order of precedence for those (i.e. props are always set, subviews throw if a prop exists in that namespace already).

This may also need to be functionality that's pushed down into ampersand-state (like having a safe-set internal function that ampersand-view can use when setting things like subviews to the local namespace).

Make renderSubview's selector argument optional

The last argument for this.renderSubview in a view is for a selector specifying where to append the element. The docs say imply it is optional ("1. fetch your container (if you gave it a selector string)") but I think this might be wrong because it causes an uncaught exception when I don't supply this argument (Uncaught TypeError: Cannot read property 'appendChild' of undefined at the line container.appendChild(view.el); within the renderSubview function).

Anyway... it seems to be possible to pass this.el instead of a selector, and this works fine. It would be nice if this was the default, for if you don't specify a selector.

Error when removing Views: _subviews is undefined

When calling a View's remove() method, ampersand-view tries to remove subViews, too. Even if they don't exist. This causes an error much further on when Underscore's flatten() tries to flatten a variable which it expects to be an array, but is actually undefined. In Chrome, the error I get is:

Uncaught TypeError: Cannot read property 'length' of undefined

Changing line 134 from

_.chain(this._subviews).flatten().invoke('remove');

to

if (this._subviews) _.chain(this._subviews).flatten().invoke('remove');

Fixes the error.

However, I don't know if this is perhaps indicative of a deeper bug, like perhaps Views ought to be initialized with an empty array _subviews property instead. If not, I'm happy to create a pull request.

If using `subviews` hash, do we need `registerSubview` or `renderSubview`?

The subviews documentation is pretty thin compared to most of Ampersand. I’m interesting in using the subviews hash to write my subviews declaratively, but I’m having trouble getting it to work.

  • In particular, do subviews in the subviews hash get auto-registered?
  • In render(), do I call this.renderSubview?
  • How would I do something like subview.collection.on('add', function() {…})? (In other words, if I want to attach the listener inside the parent view’s initialize, how do I refer to the subview I want from the hash?)

switch binding case values should accept like-selectors

desired - but not possible due to switch value/selector bug described below.

bindings: {
    'model.mediaStatus': {
        type:  'switch',
        cases: {
            'uploading':  '.progress',
            'processing': '.progress',
            'encoding':   '.progress',
            'failed':     '.failed',
/*jshint -W044*/  //ignore warning: Bad or unnecessary escaping.
            'not\ sent':  '.actions',
/*jshint +W044*/
            'finished':   '.actions'
        }
    }
}

jade:

.progress
    .progress-bar.progress-bar-striped.active(role='progressbar', aria-valuenow='100', aria-valuemin='100', aria-valuemax='100', style='width: 100%')
        span(data-hook='mediaStatus')

^when the value of model.mediaStatus is uploading, processing or encoding, i want to show the progress bar, and similarly with the .actions DIV.

however changes to model.mediaStatus will not keep .progress visible. thus you cannot use the same selector for multiple values.


i had to create three separate progress DIVs classed .uploading, .processing and .encoding to get this switch binding to work as expected.

compromise - to make the switch-binding work:

bindings: {
    'model.mediaStatus': {
        type:  'switch',
        cases: {
            'uploading':  '.uploading',
            'processing': '.processing',
            'encoding':   '.encoding',
            'failed':     '.failed',
/*jshint -W044*/  //ignore warning: Bad or unnecessary escaping.
            'not\ sent':  '.actions',
/*jshint +W044*/
            'finished':   '.actions'
        }
    }
}

jade:

.progress.uploading
    .progress-bar.progress-bar-success.progress-bar-striped.active(role='progressbar', aria-valuenow='100', aria-valuemin='100', aria-valuemax='100')
        span uploading
.progress.processing
    .progress-bar.progress-bar-striped.active(role='progressbar', aria-valuenow='100', aria-valuemin='100', aria-valuemax='100')
        span processing
.progress.encoding
    .progress-bar.progress-bar-warning.progress-bar-striped.active(role='progressbar', aria-valuenow='100', aria-valuemin='100', aria-valuemax='100')
        span encoding

i would have rather had one .progress element and [also] bound the value of model.mediaStatus to a child SPAN element. however i was forced to create three separate progress bars w/o the value-binding.

Consider supporting two way binding view<->model?

The foundation for it already exists in the bindings array, all that's left is to apply the dom watch and update the model.

I think this option could really accelerate Ampersand forward (as opposed to already far behind Backbone).
One could add an option of twoWay as to still be able to do the 1-way binding from model to view (but not other way around).

I find that in almost all cases when I tie a model to view, I also want to tie the view back to the model.

Returning collectionView on renderCollection()

To make it possible to track your model views of your collections. It is very handy to have a reference to the collectionView. Currently you can only use a syntax like this._subviews[1].views but this is rather hacky, nor do you exactly know what view it is.

By giving renderCollection() a return value of collectionView you can easily do
this.views = this.renderCollection();
_.each(this.views, function(view){});

I created a Pull Request:
#41

jQuery plugins dont work with event hash

In my experience, almost all jQuery plugins use $(el).trigger('eventname') to propagate events.

In the Backbone world, these plugins worked with the events hash because under the hood Backbone used $(el).on and $(el).off. But I think (and I'm not sure why yet) that this doesn't work with events-mixin which is what we use under the hood.

To fix this, delegateEvents and undelegateEvents can be overwritten to use jQuery which isn't too difficult.

But what made it more difficult was that in an app I was switching from Backbone to Ampersand I was using the pattern of making $el a derived property (as seen in ampersand-view-jquery-mixin). This caused delegateEvents to be called when $el wasn't ready yet (or it would then be switched out, I'm not sure exactly) which would cause none of the bound events to fire.

That last part is a different issue, but I think is worth documenting (here or elsewhere) because there are so many jQuery plugins out there for tap/swipe events and even more that trigger dom events via jQuery (twitter typeahead, bootstrap, etc).

Overall, I'm not sure what the ideal solution for this is, but it seems like something that a lot of people (especially those migrating old apps) will run into.

Please explain how subviews work

I try to build a simple app showing a console and a log of the executed commands. I have a main view and two subviews: one for the current command, and one for the log:

var View = require('ampersand-view');
var AmpersandModel = require('ampersand-model');
var AmpersandCollection = require('ampersand-collection');

var domready = require('domready');

// VIEWS
var MainView = View.extend({
    template: '\
        <body>\
            <h1>Hoodie Console</h1>\
            <div data-hook="console">\
            </div>\
            <div data-hook="log">\
            </div>\
        </body>',
    autoRender: true,

    subviews: {
      input: {
        hook: 'console',
        waitFor: 'model',
        constructor: ConsoleView
      },
      log: {
        hook: 'log',
        waitFor: 'collection',
        constructor: LogView
      }
    },
});
var ConsoleView = View.extend({
    template: '\
        <div data-hook="console">\
            &gt; <span data-hook="command"></span>\
        </div>',
    autoRender: true,
    bindings: {
        'model.name': { hook: 'name'}
    }
});
var LogView = View.extend({
    template: '\
        <div data-hook="log">\
            <h2>Log</h2>\
            <ul data-hook="commands"></ul>\
        </div>',
    autoRender: true,
    render: function() {
        this.renderWithTemplate(this);
        this.renderCollection(this.collection, LogItemView, this.queryByHook('commands'));
        return this;
    }
});
var LogItemView = View.extend({
    template: '<li data-hook="command"></li>'
})

// MODELS
var Command = AmpersandModel.extend({
    props: {
        name: ['string', true, '']
    }
});
var CommandCollection = AmpersandCollection.extend({
    model: Command,
    bindings: {
        'model.name': { hook: 'name'}
    }
});

var app = window.app = {};
domready(function () {
    app.command = new Command({name: 'hoodie.'});
    app.commands = new CommandCollection({
        name: 'hoodie.account.signUp("foo", "bar")'
    },{
        name: 'hoodie.store.findAll()'
    },{
        name: 'hoodie.id()'
    });
    self.view = new MainView({
        el: document.body,
        model: app.command,
        collection: app.commands
    });
});

The resulting HTML currently looks like

<body>
    <h1>Hoodie Console</h1>
    <div data-hook="console"></div>
    <div data-hook="log"></div>
</body>

I'd have expected

<body>
    <h1>Hoodie Console</h1>
    <div data-hook="console">&gt; <span data-hook="command">hood.ie</span></div>
    <div data-hook="log">
        <h2>Log</h2>
        <ul data-hook="commands">
                <li data-hook="command">hoodie.account.signUp("foo", "bar")</li>
                <li data-hook="command">hoodie.store.findAll()</li>
                <li data-hook="command">hoodie.id()</li>
        </ul>
    </div>
</body>

I'm sure I get something very basic wrong, I'm just getting started with &.js. Could you please help me to get this right?

Here's my package.json for reference

{
  "name": "hoodie-console",
  "version": "0.0.0",
  "description": "Hoodie Console",
  "main": "server.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Gregor Martynus <[email protected]>",
  "license": "MIT",
  "dependencies": {
    "ampersand-collection": "~1.3.16",
    "ampersand-model": "~4.0.2",
    "ampersand-view": "~6.0.11",
    "domready": "^1.0.5",
    "hapi": "^6.0.2",
    "moonboots_hapi": "^2.3.2"
  }
}

Should get return this.el if it matches the selector?

There's a few inconsistencies in the querying api:

If my template is:

<div role=editor>
  <stuff />
</div>

ie role=editor is the root element, then...

this works as expected, effectively binding clicks to view.el

events: {
  'click [role=editor]' : function () { ... }
}

this works as expected, and returns view.el

view.getByRole('editor'

but this doesn't:

view.cacheElements({
  editor: '[role=editor]'
})

//view.editor -> undefined

and this doesn't

view.get('[role=editor]') //-> undefined.

`.get` should possibly be renamed

The method was named get from before when view wasn't based on ampersand state.

State already has a get() 😢

But... i'm not sure it really matters since, basically no one, except those transitioning a backbone app, uses it. But even in that case, backboneview didn't have a get so, i'm not sure it really matters.

Just figured i'd raise it.

"Views can only have one root element" error only in IE9

I had a bug that took a long time to find. I'm documenting it here as a closed issue, so anyone else (or me) googling for the same problem in future can save time.

The problem: ampersand-view was throwing "Views can only have one root element." But only in IE9.

My view's template function returned a string of HTML something like this:

<div><label>hi<label></div>

The problem was the closing label tag was actually another opening tag. This only caused a problem in IE9, which presumably has a non-standard way of recovering from 'malformed nesting' errors. In this case the other browsers probably created a tree something like div>label>{hi}+label, but IE9 somehow put the extra label outside the div, so the resulting object was a document fragment
not an element, so ampersand-view threw.

Binding to blur/focus events doesn't work.

And so we begin to run into the things that jQuery did for us...

AmpersandView.extend({
   template: "<div><input class=foo /></div>",
   events: {
      'focus input' : 'doSomething'
   },
   doSomething: function () {
      //I'm never called :(
   }
})

Apparently focus/blur events don't bubble in browsers. jQuery fixes this for you transparently, by mapping to focusin/focusout events instead, and I think doing something extra for firefox too because that doesn't support focusin/focusout (https://developer.mozilla.org/en-US/docs/Web/Reference/Events/focus#Event_delegation)

Subviews with trailing HTML comment will not be rendered

Hi,
I realize there's probably not much for Ampersand to do in this case, but I did want to let you all know about the scenario. A subview with a trailing comment doesn't get rendered. So if the subview template looks like the one below, it will get rejected because it has more than one root element:

<div class="hoodie-accountbar">
    <div class="one">Hello</div>
    <div class="more">world</div>
</div><!--/hoodieAccountBar -->

The problem is actually in domify - it considers the comment as a node, and returns the newDom object with a nodeName of "#document-fragment" . @tnguyen14 (also an Ampersand developer) submitted an issue against domify (sindresorhus/domify#35), but they closed it as expected behavior (since the trailing comment actually is a node).
Just letting you know in case people are confused about why their subviews might be getting kicked back for having more than one root element.

Uncaught exception when using renderCollection

When I try to use .renderCollection() in a view, I seem to get this error:

Uncaught NotFoundError: Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.

My code:

    ...
    render: function(opts) {
        var self = this;
        this.renderWithTemplate(this);
        var productsElm = self.getByRole("products");
        productsElm.innerHTML = "howdy " + self.model.dateCreated;//I can get the element and write to it just fine.
        self.renderCollection(self.model.products, UserOrderProductView, productsElm, opts);//this fails
        return this;
    }
    ...

As far as I can tell, it only fails when creating the parent view from another .renderCollection(). This may be a red herring, however.

Other symptoms: I'm able to use .getByRole() to get the element just fine, and can even insert content into it just fine.

Any ideas what may be causing this?

Cannot read property 'model' of undefined

I am trying to set up a simple welcome view (displayed automatically as the root path of my router, can provide that code if needed), but I keep getting this Chrome console error:

Cannot read property 'model' of undefined

I tried making my code as bear-bones as possible, as you can see.

views/welcome.js:

var AmpersandView = require('ampersand-view');

module.exports = AmpersandView.extend({
  // I originally defined `el` as 'body' and `render` as a function that renders a React.js
  // component to this.el, but commented it out to demonstrate that that code is unrelated.
});

router.js:

var WelcomeView = require('./views/welcome');

module.exports = AmpersandRouter.extend({
  routes: {
    '': 'welcome'
  },

  welcome: function () {
    new WelcomeView().render();
  }
});

app.js:

var Router = require('./router');

function Application() {
  this.router = new Router();
}

Application.prototype.initialize = function () {
  this.router.history.start({ pushState: true });
  // this.router.navigate('/', { trigger: false });
};

module.exports = Application;

index.js:

var Application = require('./app');

new Application().initialize();

The index.js is the starting point, which initializes the app, which in turn initializes the router, which as I understand is automatically loading the root app (my welcome view). It appears that the AmpersandView.extend call is succeeding, while the new WelcomeView() call is what's throwing the error.

Using 'remove' as an event callback

I recently tried to use "remove" as an event callback. When the view was removed my remove callback was also called which in my case was removing the item from my database. Is a reason why there isn't a methods hash for event bindings to use?

Sorry if that is a dumb question. No one reponded in IRC so i decided to post here.

Create a backwards compat shim for backbone views/human views.

Suggested by: @whobubble, the two things that are tripping him up transitioning from humanview are a lack of this.$ and this.$el, we've covered this.$ already in the learn page, but a mixin that also updated this.$el would be really useful too.

I think it would be as little code as this: but it's late and it's been a long day :)

derived: {
   '$el': {
     deps: ['el'],
     fn: function () {
       if (this.el) return this.$(this.el);
     }
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.