Saturday, July 27, 2013

JSON UI Generator: Array Support

I've been asked to blog about array support in Metawidget. Displaying and manipulating arrays is fraught with personal choices about UI design. For example: is item deletion done by a right-click menu? Or a delete icon on the end of each row? Or at the start of each row? Is addition done using a blank row at the end of the table? Or as a footer row? And so on. It's the same problem we encountered for server-side rendering.

Because of this, Metawidget only goes so far out-of-the-box. metawidget.widgetbuilder.HtmlWidgetBuilder will render an array as a read-only table, but no further. Here's a complete example:

<!DOCTYPE HTML>
<html>
   <head>
      <script src="http://metawidget.org/js/3.5/metawidget-core.min.js"></script>
      <style>
         #metawidget {
            border: 1px solid #cccccc;
            width: 250px;
            border-radius: 10px;
            padding: 10px;
            margin: 50px auto;
         }
      </style>
   </head>
   <body>
      <div id="metawidget"/>
      <script type="text/javascript">
         var mw = new metawidget.Metawidget( document.getElementById( 'metawidget' ));
         mw.toInspect = {
            firstname: 'Homer',
            surname: 'Simpson',
            age: 36,
            family: [ {
               id: 0,
               firstname: 'Marge',
               surname: 'Simpson'
            }, {
               id: 1,
               firstname: 'Bart',
               surname: 'Simpson'
            } ]

         };
         mw.buildWidgets();
      </script>
   </body>
</html>

This will render:

For custom use cases, you can create your own WidgetBuilder and chain it together with the original ones using metawidget.widgetbuilder.CompositeWidgetBuilder. Then your own WidgetBuilder can handle special cases (like arrays) and 'fall back' to the original WidgetBuilders for everything else. Remember Metawidget only strives to automate what you'd normally do manually. Metawidget is not trying to be a big new framework. So the general approach is a) work out how to do something manually; b) write a WidgetBuilder to automate it.

As a shortcut, the existing metawidget.widgetbuilder.HtmlWidgetBuilder also has some methods you can override like createTable and addColumn. Here's a complete example:

<!DOCTYPE HTML>
<html>
   <head>
      <script src="http://metawidget.org/js/3.5/metawidget-core.min.js"></script>
      <style>
         #metawidget {
            border: 1px solid #cccccc;
            width: 350px;
            border-radius: 10px;
            padding: 10px;
            margin: 50px auto;
         }
      </style>
   </head>
   <body>
      <div id="metawidget"/>
      <script type="text/javascript">
         var _myWidgetBuilder = new metawidget.widgetbuilder.HtmlWidgetBuilder();
         var _superAddRow = _myWidgetBuilder.addRow;
         _myWidgetBuilder.addRow = function( tbody, value, columnAttributes, mw ) {

            var tr = _superAddRow.call( this, tbody, value, columnAttributes, mw );
            var anchor = document.createElement( 'a' );
            anchor.setAttribute( 'href', '#' );
            anchor.setAttribute( 'onclick', 'this.parentNode.parentNode.parentNode.removeChild( this.parentNode.parentNode )' );
            anchor.innerHTML = 'Delete';
            var td = document.createElement( 'td' );
            td.appendChild( anchor );
            tr.appendChild( td );

            return tr;
         };

         var mw = new metawidget.Metawidget( document.getElementById( 'metawidget' ), {
            widgetBuilder: _myWidgetBuilder
         } );
         mw.toInspect = {
            firstname: 'Homer',
            surname: 'Simpson',
            age: 36,
            family: [ {
               id: 0,
               firstname: 'Marge',
               surname: 'Simpson'
            }, {
               id: 1,
               firstname: 'Bart',
               surname: 'Simpson'
            } ]
         };
         mw.buildWidgets();
      </script>
   </body>
</html>

UPDATE: signature of addRow has changed in newer releases of Metawidget to be addRow( tbody, value, row, columnAttributes, elementName, attributes, mw ). Please update the function declaration and .call lines appropriately.

This will render:

Of course, you may also need fine-grained control over what columns you display, and in what order. For this, you can use JSON Schema to describe the schema of your array items. Here's a complete example:

<!DOCTYPE HTML>
<html>
   <head>
      <script src="http://metawidget.org/js/3.5/metawidget-core.min.js"></script>
      <style>
         #metawidget {
            border: 1px solid #cccccc;
            width: 350px;
            border-radius: 10px;
            padding: 10px;
            margin: 50px auto;
         }
      </style>
   </head>
   <body>
      <div id="metawidget"/>
      <script type="text/javascript">
         var mw = new metawidget.Metawidget( document.getElementById( 'metawidget' ), {
            inspector: new metawidget.inspector.CompositeInspector( [
               new metawidget.inspector.PropertyTypeInspector(),
               new metawidget.inspector.JsonSchemaInspector( {
                  properties: {
                     family: {
                        items: {
                           properties: {
                              id: {
                                 hidden: true
                              },
                              employer: {
                                 type: 'string'
                              }
                           }
                        }
                     }
                  }
               } )

            ] )
         } );
         mw.toInspect = {
            firstname: 'Homer',
            surname: 'Simpson',
            age: 36,
            family: [ {
               id: 0,
               firstname: 'Marge',
               surname: 'Simpson'
            }, {
               id: 1,
               firstname: 'Bart',
               surname: 'Simpson'
            } ]
         };
         mw.buildWidgets();
      </script>
   </body>
</html>

This will render:

Note the JSON Schema is being combined with the inspection results from PropertyTypeInspector, so you don't have to re-specify columns like firstname and surname, or attributes like the type of id.

Feedback welcome!

18 comments:

Charlie Kuharski said...

Is there a way to have the Array editable?

Charlie Kuharski said...

Is there a way to have the Array elements editable?

Richard Kennard said...

In general you'll need to create a custom WidgetBuilder for this. There are so many personal preferences about how to edit an array (In-place? Pop-up box? Read-only at first then click to edit? etc) that Metawidget doesn't try and cater for them all.

Having said that, I am making a very simple case easier for Metawidget 3.6. You'll be able to pass { alwaysUseNestedMetawidgetInTables: true } as a config to HtmlWidgetBuilder, and it'll use nested Metawidgets (rather than labels) for every row. If your parent Metawidget (and/or the array) is not read-only, this'll make the row elements editable.

This is already in GitHub if you want to give it a try?

Zhifeng Wang said...

Hi Richard,
Thank you for this awesome work. We are using it in some serious projects.
An issue blocked us: the edited value in nested items are not saved back to the item to inspect.
I called following, the top level property is saved but nested items are not:

mw.getWidgetProcessor(
function(widgetProcessor){return widgetProcessor instanceof metawidget.widgetprocessor.SimpleBindingProcessor;}
).save(mw);

Can you kindly resolve this?

Zhifeng Wang said...

btw: we are using the { alwaysUseNestedMetawidgetInTables: true }configuration.

Richard Kennard said...

Thanks for your kind words. I'd be interested to learn more about your projects.

Apologies for the bug. Now fixed. Can you please test?

You can find the code in GitHub here:

https://github.com/metawidget/metawidget/commit/e192b4a754903c55fabb8b6374f577f3eb0cb750

...or wait for the next nightly build (21-Feb-2014):

https://kennardconsulting.ci.cloudbees.com/job/Metawidget

Zhifeng Wang said...

Hi Richard,

Thanks you for the speedy fix! I've tried it in my project and it works great now. You gave me strong confidence to chose Metawidget as one of the core component of our solution.

We are now working on a small business modeling language to orchestrate the construction of web based Office Automation systems. Basically, such a system is an combination of business entities, workflows, user and resource management. We choose Metawidget to present the business entities.

Zhifeng Wang said...

Hi Richard,

Another enhancement is appreciated: I tried to remove/add nested array nodes, but the dom update is not reflected to the inspected data. Can you provide a way for this?

Richard Kennard said...

Delete buttons are not provided by default, but this blog shows a way to implement them. So in the 'onclick' of the delete button, as well as changing the DOM, you'll also need to update the backing object.

You can do this quite easily. The addRow() method is passed the value (which is the array) and the row number. So something like:

deleteButton.on( 'click', function() { value.splice( row, 0 ); }

Should do the trick?

Zhifeng Wang said...

Thanks for the advice! I've made add and delete work in your suggested way. Attach the code to save some minutes for others who might interest(jQuery reqired):

var mw;

function SetData() {
//to support "Delete" on each nested item.
var _myWidgetBuilder = new metawidget.widgetbuilder.HtmlWidgetBuilder({ alwaysUseNestedMetawidgetInTables: true });
var _superAddRow = _myWidgetBuilder.addRow;
_myWidgetBuilder.addRow = function (tbody, value, row, columnAttributesArray, elementName, tableAttributes, mw) {
var tr = _superAddRow.call(_myWidgetBuilder,tbody, value, row, columnAttributesArray, elementName, tableAttributes, mw);
var anchor = document.createElement( 'a' );
anchor.setAttribute('href', '#');

$(anchor).click(function () {
mw.extSave();
value.splice(row, 1);
mw.buildWidgets();
});

anchor.innerHTML = 'Delete';
var td = document.createElement( 'td' );
td.appendChild( anchor );
tr.appendChild( td );

return tr;

}

//to support "New" to nested array item.
var _superCreateTable = _myWidgetBuilder.createTable;
_myWidgetBuilder.createTable = function (elementName, attributes, mw) {
var table = _superCreateTable.call(_myWidgetBuilder, elementName, attributes, mw);

var typeAndNames = metawidget.util.splitPath(mw.path);
var toInspect = metawidget.util.traversePath(mw.toInspect, typeAndNames.names);
var value = toInspect[attributes.name];

var newItem = function () {
var typeAndNames = metawidget.util.splitPath(mw.path);

mw.extSave();

var toInspect = metawidget.util.traversePath(mw.toInspect, typeAndNames.names);
var value = toInspect[attributes.name] || new Array();
value.push(null);
mw.buildWidgets();
};

$(table).find("tbody").append("<tr><td colspan=2><button>New</button></td></tr>").find('button').click(newItem);
return table;
}

mw = new metawidget.Metawidget(document.getElementById('metawidget'), {
inspector: new metawidget.inspector.CompositeInspector([
new metawidget.inspector.PropertyTypeInspector(), new metawidget.inspector.JsonSchemaInspector(entitySchema)]),
widgetBuilder: _myWidgetBuilder
});
mw.toInspect = theEntity;
mw.extSave = function () {
mw.getWidgetProcessor(
function (widgetProcessor) {
return widgetProcessor instanceof metawidget.widgetprocessor.SimpleBindingProcessor;
}
).save(mw);
}
mw.buildWidgets();
}

SetData();

Zhifeng Wang said...

Hi Richard,

We met another issue: when we set an array as the top level item to inspect, the save method of SimpleBindingProcessor doesn't work. Can you please take a look at it? Following is the related code:

//SimpleBindingProcessor.save() works fine on following settings
//var entitySchema = { properties: { Name: { type: 'string' } } };
//var theEntity = { Name: 'zhangsan' };

//But it doesn't work for arrays as top level item.
var entitySchema = {
name:"entity",
type: "array",
items: {
type: "object",
properties: {
Name: {
type: "string"
},
Value: { type: "string" }
}
}

};
var theEntity = [
{ Name: "name1", Value: "value1" },
{ Name: "name1", Value: "value1" },
{ Name: "name1", Value: "value1" }];

var mw = new metawidget.Metawidget(document.getElementById('metawidget'), {
inspector: new metawidget.inspector.JsonSchemaInspector(entitySchema),
widgetBuilder: new metawidget.widgetbuilder.HtmlWidgetBuilder({ alwaysUseNestedMetawidgetInTables: true })
});
mw.toInspect = theEntity;
mw.buildWidgets();

//called when user click' save'
function Save() {
mw.getWidgetProcessor(
function (widgetProcessor) {
return widgetProcessor instanceof metawidget.widgetprocessor.SimpleBindingProcessor;
}
).save(mw);
alert(JSON.stringify(mw.toInspect));
}

Richard Kennard said...

Apologies. Now fixed. Can you please try in GitHub...

https://github.com/metawidget/metawidget/commit/dbb829eea13c5aa144c1e2b3c220b77282478e3e

...and/or wait for the nightly build (27-Feb-2014)?

Zhifeng Wang said...

Hi Richard,

You fix works well. Thanks.

Now we met following situation: we want to bind simple data items to metawidget(in our nested widget senario, this will be more convinient for us than wrap it in an object). The binding works fine, but the save method seems not work. I always get the original value after save() called. Following is the code:

<body>
<div id="metawidget"></div>
<button onclick="Save()">Save</button>
<script type="text/javascript">
var entity = 'a';
var mw = new metawidget.Metawidget(document.getElementById('metawidget'), {
inspector: new metawidget.inspector.JsonSchemaInspector({ type: 'string' })
});
mw.toInspect = entity;

mw.buildWidgets();

function Save() {

mw.getWidgetProcessor(
function (widgetProcessor) {
return widgetProcessor instanceof metawidget.widgetprocessor.SimpleBindingProcessor;
}
).save(mw);

alert(mw.toInspect);//will still be 'a';
}
</script>
</body>

Richard Kennard said...

Zhifeng,

Thanks for the report! Now fixed in GitHub and nightly build (05-Mar-2014). Please test.

Regards,

Richard.

Zhifeng Wang said...

Hi Richard,

Thanks for the quick fix. It works well. Thanks!

Anonymous said...

Hi, Richard I am trying same with 4.2 version to create table from json object but it's giving error Uncaught TypeError: Cannot read property 'ownerDocument' of undefined

Richard Kennard said...

Can you please send some code to support@metawidget.org?

Richard Kennard said...

Apologies. Signature of addRow has changed. See newly added text in red in the blog post