Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Binding to arbitrarily deep properties on an object based on rules

(I'm sorry if my question title isn't very good, I couldn't think of a better one. Feel free to suggest better options.)

I'm trying to create a reusable "property grid" in Angular, where one can bind an object to the grid, but in such a way that presentation of the object can be customized somewhat.

This is what the directive template looks like (the form-element isn't important to my question, so I'll leave it out):

<div ng-repeat="prop in propertyData({object: propertyObject})">
    <div ng-switch on="prop.type">
        <div ng-switch-when="text">
            <form-element type="text"
                          label-translation-key="{{prop.key}}"
                          label="{{prop.key}}"
                          name="{{prop.key}}"
                          model="propertyObject[prop.key]"
                          focus-events-enabled="false">
            </form-element>
        </div>
    </div>
</div>

and, the directive code:

angular.module("app.shared").directive('propertyGrid', ['$log', function($log) {
    return {
        restrict: 'E',
        scope: {
            propertyObject: '=',
            propertyData: '&'
        }
        templateUrl: 'views/propertyGrid.html'
    };
}]);

Here's an example usage:

<property-grid edit-mode="true"
               property-object="selectedSite"
               property-data="getSitePropertyData(object)">
</property-grid>

And the getSitePropertyData() function that goes with it:

var lastSite;
var lastSitePropertyData;
$scope.getSitePropertyData = function (site) {
    if (site == undefined) return null;

    if (site == lastSite)
        return lastSitePropertyData;

    lastSite = site;
    lastSitePropertyData = [
        {key:"SiteName", value:site.SiteName, editable: true, type:"text"},
        //{key:"Company.CompanyName", value:site.Company.CompanyName, editable: false, type:"text"},
        {key:"Address1", value:site.Address1, editable: true, type:"text"},
        {key:"Address2", value:site.Address2, editable: true, type:"text"},
        {key:"PostalCode", value:site.PostalCode, editable: true, type:"text"},
        {key:"City", value:site.City, editable: true, type:"text"},
        {key:"Country", value:site.Country, editable: true, type:"text"},
        {key:"ContactName", value:site.ContactName, editable: true, type:"text"},
        {key: "ContactEmail", value: site.ContactEmail, editable: true, type:"email"},
        {key: "ContactPhone", value: site.ContactPhone, editable: true, type:"text"},
        {key: "Info", value: site.Info, editable: true, type:"text"}
    ];
    return lastSitePropertyData;
};

The reason I'm going through such a "property data" function and not just binding directly to properties on the object is that I need to control the order of the properties, as well as whether they should even be shown to the user at all, and also what kind of property it is (text, email, number, date, etc.) for the sake of presentation.

At first, as you can tell from the value property remnant in the getSitePropertyData() function, I first tried providing the values directly from this function, but that wouldn't bind to the object, so changes either in the object or form the property grid didn't sync back and forth. Next up, then, was using the key idea, which lets me do this: propertyObject[prop.key]—which works great for direct properties, but as you can see, I had to comment out the "Company" field, because it's a property of a property, and propertyObject["a.b"] doesn't work.

I'm struggling to figure out what to do here. I need the bindings to work, and I need to be able to use arbitrarily deep properties in my bindings. I know this kind of thing is theoretically possible; I've seen it done for instance in UI Grid, but such projects have so much code that I would probably spend days finding out how they do it.

Am I getting close, or am I going about this all wrong?

like image 915
Alex Avatar asked Jun 09 '16 09:06

Alex


2 Answers

You want to run an arbitrary Angular expression on an object. That is exactly the purpose of $parse (ref). This service can well... parse an Angular expression and return a getter and setter. The following example is an oversimplified implementation of your formElement directive, demonstrating the use of $parse:

app.directive('formElement', ['$parse', function($parse) {
  return {
    restrict: 'E',
    scope: {
      label: '@',
      name: '@',
      rootObj: '=',
      path: '@'
    },
    template:
      '<label>{{ label }}</label>' +
      '<input type="text" ng-model="data.model" />',
    link: function(scope) {
      var getModel = $parse(scope.path);
      var setModel = getModel.assign;
      scope.data = {};
      Object.defineProperty(scope.data, 'model', {
        get: function() {
          return getModel(scope.rootObj);
        },
        set: function(value) {
          setModel(scope.rootObj, value);
        }
      });
    }
  };
}]);

I have altered slightly the way the directive is used, hopefully without changing the semantics:

<form-element type="text"
    label-translation-key="{{prop.key}}"
    label="{{prop.key}}"
    name="{{prop.key}}"
    root-obj="propertyObject"
    path="{{prop.key}}"
    focus-events-enabled="false">

Where root-obj is the top of the model and path is the expression to reach the actual data.

As you can see, $parse creates the getter and setter function for the given expression, for any root object. In the model.data property, you apply the accessor functions created by $parse to the root object. The entire Object.defineProperty construct could be replaced by watches, but that would only add overhead to the digest cycle.

Here is a working fiddle: https://jsfiddle.net/zb6cfk6y/


By the way, another (more terse and idiomatic) way to write the get/set would be:

  Object.defineProperty(scope.data, 'model', {
    get: getModel.bind(null, scope.rootObj),
    set: setModel.bind(null, scope.rootObj)
  });
like image 187
Nikos Paraskevopoulos Avatar answered Oct 04 '22 16:10

Nikos Paraskevopoulos


If you are using lodash you can use the _.get function to achieve this.

You can store _.get in the controller of your property-grid and then use

model="get(propertyObject,prop.key)"

in your template. If you need this functionality in multiple places in your application (and not just in property-grid) you could write a filter for this.


The problem with this is that you can't bind your model this way and thus you can't edit the values. You can use the _.set function and an object with a getter and a setter to make this work.

vm.modelize = function(obj, path) {
    return {
      get value(){return _.get(obj, path)},
      set value(v){_.set(obj, path,v)}
    };
}

You can then use the function in the template:

<div ng-repeat="prop in propertyData({object: propertyObject})">
  <input type="text"
          ng-model="ctrl.modelize(propertyObject,prop.key).value"
          ng-model-options="{ getterSetter: true }"></input>
</div>

For a reduced example see this Plunker.


If you don't use lodash you can use this simplified version of the _.get function that I extracted from lodash.

function getPath(object, path) {
  path = path.split('.')

  var index = 0
  var length = path.length;

  while (object != null && index < length) {
    object = object[path[index++]];
  }
  return (index && index == length) ? object : undefined;
}

This function makes sure that you won't get any Cannot read property 'foo' of undefined errors. This is useful especially if you have long chains of properties where there might be an undefined value. If you want to be able to use more advanced paths (like foo.bar[0]) you have to use the full _.get function from lodash.

And here is a simplified version of _.set also extracted form lodash:

function setPath(object, path, value) {
    path = path.split(".")

    var index = -1,
        length = path.length,
        lastIndex = length - 1,
        nested = object;

    while (nested != null && ++index < length) {
        var key = path[index]
        if (typeof nested === 'object') {
            var newValue = value;
            if (index != lastIndex) {
                var objValue = nested[key];
                newValue = objValue == null ?
                    ((typeof path[index + 1] === 'number') ? [] : {}) :
                    objValue;
            }


            if (!(hasOwnProperty.call(nested, key) && (nested[key] === value)) ||
                (value === undefined && !(key in nested))) {
                nested[key] = newValue;
            }


        }
        nested = nested[key];
    }
    return object;
}

Keep in mind that these extracted functions ignore some edge cases that lodash handles. But they should work in most cases.

like image 28
Till Arnold Avatar answered Oct 04 '22 17:10

Till Arnold