Monday, March 12, 2012

Dojo: Implementing a ViewModelStore

In the 1.6 release of Dojo, a new Store API was released with the intentions of improving on the design of the Data API. While I agree with most of the new Store API design, there is one feature of the Data API that I make significant use of and it was not carried forward into the Store API. The missing feature is the data structure that is used to initialize the store. In this post I will discuss why I find it important and create a store that uses the legacy structure and also implements the new Store API.

I use a data store as a page view model providing the needed data to the widgets on the page. The data has many references and I want to be able to query the store for any object within it. The legacy Data API uses a flat list of items. Any references to other items are identified using a "_reference" attribute. When the store initializes, it will replace the "_reference" object with the actual object. This allows for a normalized model with all objects queryable in the store.

The Data

For this example, the view model contains multiple orders. I want to be able to create a combobox with all of the customers and a combobox with all possible order statuses. The orders that are shown should match the customer and order status selected in the comboboxes.

var dataArr = [
  { id: 'customer::1', _type: 'customer', 
    name: 'Anderson, Jose',
    address: { _reference: 'address::1' } 
  },
  { id: 'address::1', _type: 'address', 
    address1: '2272 Stratford Drive', 
    address2: 'Honolulu, HI 96814' 
  },
  { id: 'customer::2', _type: 'customer', 
    name: 'Gomez, Marcus',
    address: { _reference: 'address::2' } 
  },
  { id: 'address::2', _type: 'address', 
    address1: '2718 College Avenue', 
    address2: 'Dayton, OH 45459' 
  },

  // ... other customers ...

  { id: 'product::1', _type: 'product', 
    name: 'Ball Original Classic Pectin Small Batch', 
    price: 1.07 
  },
  { id: 'product::2', _type: 'product', 
    name: 'Ball 4-Pk 16 Oz. Wide Mouth Class Canning Jars with Lids', 
    price: 4.48 
  },

  // ... other products ...

  { id: 'orderStatus::1', _type: 'orderStatus', 
    name: 'Pending', displayOrder: 10 
  },
  { id: 'orderStatus::2', _type: 'orderStatus', 
    name: 'Back Ordered', displayOrder: 30 
  },
  { id: 'orderStatus::3', _type: 'orderStatus', 
    name: 'Shipped', displayOrder: 20 
  },
  { id: 'order::1', _type: 'order', orderNumber: 1234, 
    customer: { _reference: 'customer::1' }, 
    orderStatus: { _reference: 'orderStatus::1' },
    lineItems:[
        { _reference: 'orderLine::1' }, 
        { _reference: 'orderLine::2' }
    ]
  },
  { id: 'orderLine::1', _type: 'orderLine', 
    order: { _reference: 'order::1' }, 
    price: 1.07, quantity: 10, 
    product: { _reference: 'product::1' } 
  },
  { id: 'orderLine::2', _type: 'orderLine', 
    order: { _reference: 'order::1' }, 
    price: 4.48, quantity: 1, 
    product: { _reference: 'product::2' } 
  },

  // ... other orders ...
];

The ViewModelStore

The ViewModelStore extends the MemoryStore and the core component is the implementation of setData. Here, each item in the array is registered. After an item is registered and if previous objects contain a reference to the object, a setter function will be called and the reference to the newly registered object will be set. Using the data above as an example, after registering the address object, a previously registered setter function will be called to set the address property of the customer to this address object.
setData: function(data){
    ...
    this.index = {};
    
    // when a reference to an item has not been registered,  
    // a setter function is added and will be called when 
    // the item is registered. 
    this._referenceSetters = {};
    
    // loop through the flat list of items
    dojo.forEach(this.data, function(itm, idx) {
        
        var itmId = itm[this.idProperty]; 
        this.index[itmId] = idx;
        
        for(var prop in itm) {
            this._registerItem(itm, prop); 
        }
        
        // process the queue of deferred setters for this object.
        if (this._referenceSetters[itmId]) {
            dojo.forEach(this._referenceSetters[itmId], 
              function(fnSet) { fnSet(itm); }
            );
            this._referenceSetters[itmId] = null;
        }
        
    }, this);
    
    delete this._referenceSetters;
}
Registering an item determines if it is an array or not. If so, it will call the inspectValue function for each item of the array, otherwise it is called for the item itself. The inspectValue function determines if it is a reference to another object in the data. If it isn't, then there is nothing more to do. If it is a reference and the referenced item has already been registered, then that item replaces the object identified by the reference. If the item has not been registered, then a function to set the value is registered to be called when the reference is registered.
_inspectValue: function(val, fnSetter) {
    
    var refProperty = this.referenceProperty || '_reference';
    
    // not a 'referece', so return the value.
    if (!val || !dojo.isObject(val) || !val[refProperty]) {
        fnSetter(val);
        return; 
    }
    
    var refId = val[refProperty];
    var refItm = this.get(refId);
    
    // the referenced item has already been loaded, return it. 
    if (refItm) {
        fnSetter(refItm); 
        return;
    }
    
    //we don't have the reference yet, qo queue a deferred setter that 
    //will set the value once the item is registered.
    if (! this._referenceSetters[refId]) {
        this._referenceSetters[refId] = [];
    }
    this._referenceSetters[refId].push(fnSetter); 
},

Using the ViewModelStore

The code snippet below creates two comboboxes and populates them using the data from the store. The onChange events are then wired to a function that queries the store using the filters specified in the comboboxes and renders the orders. If a new status is to be added later, it simply has to be put into the view model and the page will handle it accordingly.
var customer = new ComboBox({
    store:  store,
    query:  { _type: 'customer' },
    labelAttr: 'name'
}, dojo.byId('customerNode'));
customer.startup();

var status = new ComboBox({
    store:  store,
    query:  { _type: 'orderStatus' },
    labelAttr: 'name'
}, dojo.byId('statusNode'));
status.startup();

var fnDisplayOrders = function() {
    
    var contentNode = dojo.byId("content");
    var displayedOrder = false;
    domConstruct.empty(contentNode);
    
    var result = store.query({ 
        _type:          'order',
        customer:       customer.get('item'),
        orderStatus:    status.get('item'),
    });
    
    result.forEach(function(order) {
        displayedOrder = true;
        var div = dojo.create('div', {}, contentNode);
        dojo.addClass(div, 'order');
        
        var header = dojo.create('div', {}, div);
        dojo.addClass(header, 'orderHeader');
        header.innerHTML = dojo.replace(
          'Order #: {orderNumber}<br/>{customer.firstName}
          {customer.lastName}<br/>{customer.address.address1}
          <br/>{customer.address.address2}',
          order );
        
        ... more rendering logic ...
    });
    
};

dojo.connect(customer, 'onChange', fnDisplayOrders);
dojo.connect(status, 'onChange', fnDisplayOrders);
The complete example can be found at https://gist.github.com/2010137

1 comment: