Refactor templates to make them composable

The main goal of this change is to free the potential Merlin users
from the burden of writing their custom templates when it just
involves combining widgets into different levels of nesting. Writing
custom templates still remains obligatory when some additional
controls/rendering (not provided with built-in widgets) is needed,
e.g. YAQLField.

To ease the pain of laying out the DOM snippets not known in advance I
switched from conventional Bootstrap Grid system to the Flexgrid
package which reimplements Bootstrap Grid over CSS3 Flexbox module. It
provides all the existing grid features w/o the need to cancel
floating effects with div.clearfix and adds pretty vertical/horizontal
aligning options which are very useful in Merlin.

Besides templates refactoring the filters system was also
rewritten. Filter extractPanels() now accepts one required argument,
keyExtractor function which is used to calculate a numeric values for
every field of Barricade object recursively. The fields with the same
numeric values go to the same panel, so we could define the logic of
panel extraction separately for each application built on Merlin. For
the filters following on the pipeline extractPanels() provides .each()
method, which they should use for enumeration over the panel
contents. This way the panel implements the same interface as every
other Barricade container does.

Old extractRows() and extractItems() filters are removed, as well as
the necessity to embed positioning hints into the model. As of now
precise fields ordering is lost, but will be reimplemented with an
extractFields() upgrade (ability to pass a list of field keys is yet
to come, as well as the removal of 'index' hints).

Implements blueprint: composable-templates
Implements blueprint: decouple-ui-hints-and-models

Change-Id: I73f480034730099b33afec88cddf919a7bfc441b
This commit is contained in:
Timur Sufiev 2015-07-15 17:03:21 +03:00
parent e17565a708
commit f605ff7b8c
29 changed files with 807 additions and 1273 deletions

View File

@ -14,7 +14,8 @@
"angular-moment": "0.9.0",
"angular-cache": "3.2.5",
"js-yaml": "3.2.7",
"underscore": "1.8.3"
"underscore": "1.8.3",
"flexboxgrid": "6.2.0"
},
"devDependencies": {
"angular-mocks": "1.3.10",

View File

@ -12,7 +12,7 @@
function initModule(templates) {
templates.prefetch('/static/mistral/templates/fields/',
['varlist', 'yaqllist']);
['yaqlfield']);
}
})();

View File

@ -35,6 +35,20 @@
});
};
// Please see the explanation of how this determinant function works
// in the 'extractPanels' filter documentation
vm.keyExtractor = function(item, parent) {
if (item.instanceof(models.Action)) {
return 500 + parent.toArray().indexOf(item);
} else if (item.instanceof(models.Workflow)) {
return 1000 + parent.toArray().indexOf(item);
} else if (item.instanceof(Barricade.Container)) {
return null;
} else {
return 0;
}
};
function getNextIDSuffix(container, regexp) {
var max = Math.max.apply(Math, container.getIDs().map(function(id) {
var match = regexp.exec(id);

View File

@ -18,11 +18,15 @@
if ( angular.isUndefined(json) || type === String ) {
return fields.string.create(json, parameters);
} else if ( type === Array ) {
return fields.list.extend({}, {
return fields.list.extend({
inline: true
}, {
'*': {'@class': fields.string}
}).create(json, parameters);
} else if ( type === Object ) {
return fields.dictionary.extend({}, {
return fields.dictionary.extend({
inline: true
}, {
'?': {'@class': fields.string}
}).create(json, parameters);
}
@ -31,7 +35,6 @@
models.varlist = fields.list.extend({
create: function(json, parameters) {
var self = fields.list.create.call(this, json, parameters);
self.setType('varlist');
self.on('childChange', function(child, op) {
if ( op == 'empty' ) {
self.each(function(index, item) {
@ -48,6 +51,7 @@
'@class': fields.frozendict.extend({
create: function(json, parameters) {
var self = fields.frozendict.create.call(this, json, parameters);
self.isAtomic = function() { return false; };
self.on('childChange', function(child) {
if ( child.instanceof(Barricade.Enumerated) ) { // type change
var value = self.get('value');
@ -87,23 +91,23 @@
}
});
models.yaqllist = fields.list.extend({
models.YAQLField = fields.frozendict.extend({
create: function(json, parameters) {
var self = fields.list.create.call(this, json, parameters);
self.setType('yaqllist');
var self = fields.frozendict.create.call(this, json, parameters);
self.setType('yaqlfield');
return self;
}
}, {
'*': {
'@class': fields.frozendict.extend({}, {
'yaql': {
'@class': fields.string
},
'action': {
'@class': fields.string
}
})
}
});
models.yaqllist = fields.list.extend({}, {
'*': {'@class': models.YAQLField}
});
models.Action = fields.frozendict.extend({
@ -135,8 +139,7 @@
}
}, {
'@meta': {
'index': 1,
'row': 0
'index': 1
}
})
},
@ -144,18 +147,24 @@
'@class': fields.dictionary.extend({
create: function(json, parameters) {
var self = fields.dictionary.create.call(this, json, parameters);
self.isAdditive = function() { return false; };
self.setType('frozendict');
return self;
},
// here we override `each' method inherited from fields.dictionary<-MutableObject
// because it provides entry index as the first argument of the callback, while
// we need to get the key/ID value as first argument (mimicking the `each' method
// ImmutableObject)
each: function(callback) {
var self = this;
this.getIDs().forEach(function(id) {
callback.call(self, id, self.getByID(id));
});
return this;
}
}, {
'@required': false,
'?': {
'@class': fields.string.extend({}, {
'@meta': {
'row': 0
}
})
},
'?': {'@class': fields.string},
'@meta': {
'index': 2,
'title': 'Base Input'
@ -189,9 +198,6 @@
});
return self;
},
remove: function() {
this.emit('change', 'taskRemove', this.getID());
},
_getPrettyJSON: function() {
var json = fields.frozendict._getPrettyJSON.apply(this, arguments);
delete json.type;
@ -200,10 +206,7 @@
}, {
'@meta': {
'baseKey': 'task',
'baseName': 'Task ',
'group': true,
'additive': false,
'removable': true
'baseName': 'Task '
},
'type': {
'@class': fields.string.extend({}, {
@ -214,16 +217,14 @@
}],
'@default': 'action',
'@meta': {
'index': 0,
'row': 0
'index': 0
}
})
},
'description': {
'@class': fields.text.extend({}, {
'@meta': {
'index': 2,
'row': 1
'index': 2
}
})
},
@ -268,7 +269,6 @@
'@required': false,
'@meta': {
'index': 0,
'row': 0,
'title': 'Wait before'
}
})
@ -278,7 +278,6 @@
'@required': false,
'@meta': {
'index': 1,
'row': 0,
'title': 'Wait after'
}
})
@ -287,8 +286,7 @@
'@class': fields.number.extend({}, {
'@required': false,
'@meta': {
'index': 2,
'row': 1
'index': 2
}
})
},
@ -297,7 +295,6 @@
'@required': false,
'@meta': {
'index': 3,
'row': 2,
'title': 'Retry count'
}
})
@ -307,7 +304,6 @@
'@required': false,
'@meta': {
'index': 4,
'row': 2,
'title': 'Retry delay'
}
})
@ -317,7 +313,6 @@
'@required': false,
'@meta': {
'index': 5,
'row': 3,
'title': 'Retry break on'
}
})
@ -330,7 +325,6 @@
'requires': {
'@class': fields.string.extend({}, {
'@meta': {
'row': 2,
'index': 3
}
})
@ -386,7 +380,6 @@
}
}, {
'@meta': {
'row': 0,
'index': 1
}
})
@ -407,7 +400,6 @@
}
}, {
'@meta': {
'row': 0,
'index': 1
}
})
@ -446,8 +438,7 @@
'@enum': ['reverse', 'direct'],
'@default': 'direct',
'@meta': {
'index': 1,
'row': 0
'index': 1
}
})
},
@ -485,16 +476,13 @@
var taskData = child.toJSON();
params.id = taskId;
self.set(taskPos, TaskFactory(taskData, params));
} else if ( op === 'taskRemove' ) {
self.removeItem(arg);
}
});
return self;
}
}, {
'@meta': {
'index': 5,
'group': true
'index': 5
},
'?': {
'@class': models.Task,
@ -511,9 +499,7 @@
'@class': fields.frozendict.extend({}, {
'@required': false,
'@meta': {
'index': 4,
'group': true,
'additive': false
'index': 4
},
'on-error': {
'@class': models.yaqllist.extend({}, {
@ -557,8 +543,7 @@
models.Actions = fields.dictionary.extend({}, {
'@required': false,
'@meta': {
'index': 3,
'panelIndex': 1
'index': 3
},
'?': {
'@class': models.Action
@ -583,8 +568,7 @@
}
}, {
'@meta': {
'index': 4,
'panelIndex': 2
'index': 4
},
'?': {
'@class': models.Workflow,
@ -601,9 +585,7 @@
'@class': fields.string.extend({}, {
'@enum': ['2.0'],
'@meta': {
'index': 2,
'panelIndex': 0,
'row': 1
'index': 2
},
'@default': '2.0'
})
@ -611,9 +593,7 @@
'name': {
'@class': fields.string.extend({}, {
'@meta': {
'index': 0,
'panelIndex': 0,
'row': 0
'index': 0
},
'@constraints': [
function(value) {
@ -625,9 +605,7 @@
'description': {
'@class': fields.text.extend({}, {
'@meta': {
'index': 1,
'panelIndex': 0,
'row': 0
'index': 1
},
'@required': false
})

View File

@ -1,91 +0,0 @@
<collapsible-group content="value"
on-add="value.add()">
<div class="three-columns" ng-repeat="subItem in value.getValues() track by $index"
ng-class="subItem.get('type').get()">
<div class="left-column">
<div class="form-group">
<label for="elem-{$ $id $}.$index">Key Type</label>
<select id="elem-{$ $id $}.$index" class="form-control"
ng-model="subItem.get('type').value" ng-model-options="{getterSetter: true}">
<option ng-repeat="value in subItem.get('type').getEnumValues()"
value="{$ value $}"
ng-selected="subItem.get('type').get() == value">{$ value $}</option>
</select>
</div>
</div>
<div ng-switch="subItem.get('type').value()">
<!-- draw string input -->
<div class="right-column" ng-switch-when="string">
<div class="form-group">
<label>&nbsp;</label>
<div class="input-group">
<input type="text" class="form-control"
ng-model="subItem.get('value').value" ng-model-options="{getterSetter: true}">
<span class="input-group-btn">
<button class="btn btn-default" ng-click="value.remove($index)">
<i class="fa fa-minus-circle"></i>
</button>
</span>
</div>
</div>
</div>
<!-- END: draw string input -->
<!-- draw dictionary inputs -->
<div ng-switch-when="dictionary">
<div ng-repeat="(key, value) in subItem.get('value').getValues() track by key">
<div ng-hide="$first" class="left-column"></div>
<div class="right-column">
<div class="form-group">
<label for="elem-{$ $id $}.{$ key $}">
<editable ng-model="value.keyValue" ng-model-options="{getterSetter: true}"></editable>
</label>
<div class="input-group">
<input type="text" id="elem-{$ $id $}.{$ key $}" class="form-control" ng-model="value.value"
ng-model-options="{getterSetter: true}">
<span class="input-group-btn">
<button class="btn btn-default" ng-click="subItem.get('value').remove(key)">
<i class="fa fa-minus-circle"></i>
</button>
</span>
</div>
</div>
</div>
<div ng-hide="$last" class="clearfix"></div>
<div ng-show="$last" class="add-btn button-column">
<button class="btn btn-default btn-sm pull-right" ng-click="subItem.get('value').add()">
<i class="fa fa-plus"></i>
</button>
</div>
</div>
</div>
<!-- END: draw dictionary inputs -->
<!-- draw list inputs -->
<div ng-switch-when="list">
<div ng-repeat="value in subItem.get('value').getValues() track by $index">
<div ng-hide="$first" class="left-column"></div>
<div class="right-column">
<div class="form-group">
<label ng-show="$first">&nbsp;</label>
<div class="input-group">
<input type="text" class="form-control" ng-model="value.value"
ng-model-options="{getterSetter: true}">
<span class="input-group-btn">
<button class="btn btn-default" ng-click="subItem.get('value').remove($index)">
<i class="fa fa-minus-circle"></i>
</button>
</span>
</div>
</div>
</div>
<div ng-hide="$last" class="clearfix"></div>
<div ng-show="$last" class="add-btn button-column" ng-class="{'varlist-1st-row': !$index}">
<button class="btn btn-default btn-sm pull-right" ng-click="subItem.get('value').add()">
<i class="fa fa-plus"></i>
</button>
</div>
</div>
</div>
<!-- END: draw list inputs -->
</div>
</div>
</collapsible-group>

View File

@ -0,0 +1,22 @@
<div class="row">
<div class="col-xs" ng-show="value.showYaql">
<div class="form-group">
<textarea class="form-control" ng-model="value.get('yaql').value"
ng-model-options="{getterSetter: true}"></textarea>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<div class="input-group">
<span class="input-group-btn">
<button class="btn btn-default" ng-click="value.showYaql = !value.showYaql;">
<i class="fa"
ng-class="{'fa-lock': value.get('yaql').value(), 'fa-unlock': !value.get('yaql').value()}"></i>
</button>
</span>
<input type="text" class="form-control" ng-model="value.get('action').value"
ng-model-options="{getterSetter: true}">
</div>
</div>
</div>
</div>

View File

@ -1,30 +0,0 @@
<collapsible-group content="value" on-add="value.add()">
<div class="three-columns"
ng-repeat="subItem in value.getValues() track by $index">
<div class="left-column" ng-show="subItem.showYaql">
<div class="form-group">
<textarea class="form-control" ng-model="subItem.get('yaql').value"
ng-model-options="{getterSetter: true}"></textarea>
</div>
</div>
<div class="right-column">
<div class="form-group">
<div class="input-group">
<span class="input-group-btn">
<button class="btn btn-default" ng-click="subItem.showYaql = !subItem.showYaql;">
<i class="fa"
ng-class="{'fa-lock': subItem.get('yaql').value(), 'fa-unlock': !subItem.get('yaql').value()}"></i>
</button>
</span>
<input type="text" class="form-control" ng-model="subItem.get('action').value"
ng-model-options="{getterSetter: true}">
<span class="input-group-btn">
<button class="btn btn-default" ng-click="value.remove($index)">
<i class="fa fa-minus-circle"></i>
</button>
</span>
</div>
</div>
</div>
</div>
</collapsible-group>

View File

@ -33,21 +33,22 @@
{% compress css %}
<link href='{{ STATIC_URL }}merlin/scss/merlin.scss' type='text/scss' media='screen' rel='stylesheet' />
{% endcompress %}
<link href='{{ STATIC_URL }}merlin/libs/flexboxgrid/dist/flexboxgrid.css' type='text/css' media='screen' rel='stylesheet' />
{% block merlin-css %}{% endblock %}
{% endblock %}
{% block main %}
<h3>Create Workbook</h3>
<div id="create-workbook" class="fluid-container" ng-cloak ng-controller="WorkbookController as wb"
<div id="create-workbook" ng-cloak ng-controller="WorkbookController as wb"
ng-init="wb.init({{ id|default:'undefined' }}, '{{ yaml }}', '{{ commit_url }}', '{{ discard_url }}')">
<div class="well">
<div class="two-panels">
<div class="left-panel">
<div class="pull-left">
<div class="row">
<div class="col-xs row">
<div class="col-xs start-xs">
<h4><strong>{$ wb.workbook.get('name') $}</strong></h4>
</div>
<div class="pull-right">
<div class="table-actions clearfix">
<div class="col-xs end-xs">
<div class="table-actions">
<button ng-click="wb.addAction()" class="btn btn-default btn-sm">
<span class="fa fa-plus">Add Action</span></button>
<button ng-click="wb.addWorkflow()" class="btn btn-default btn-sm">
@ -55,8 +56,8 @@
</div>
</div>
</div>
<div class="right-panel">
<div class="btn-group btn-toggle pull-right">
<div class="col-xs end-xs">
<div class="btn-group btn-toggle">
<button ng-click="wb.isGraphMode = true" class="btn btn-sm"
ng-class="wb.isGraphMode ? 'active btn-primary' : 'btn-default'">Graph</button>
<button ng-click="wb.isGraphMode = false" class="btn btn-sm"
@ -65,23 +66,31 @@
</div>
</div>
<!-- Data panel start -->
<div class="two-panels">
<div class="left-panel">
<panel ng-repeat="panel in wb.workbook | extractPanels track by panel.id"
<div class="row">
<div class="col-xs">
<panel ng-repeat="panel in wb.workbook | extractPanels:wb.keyExtractor track by panel.id"
content="panel">
<div ng-repeat="row in panel | extractRows track by row.id">
<div ng-class="{'two-columns': row.index !== undefined }">
<div ng-repeat="item in row | extractItems track by item.id"
ng-class="{'right-column': item.isAtomic() && $odd, 'left-column': item.isAtomic() && $even}">
<typed-field value="item" type="{$ item.getType() $}"></typed-field>
<div class="clearfix" ng-if="$odd"></div>
<div ng-repeat="row in panel | extractFields | chunks:2 track by $index">
<div ng-repeat="(label, field) in row track by field.uid()">
<div ng-if="field.isAtomic()" class="col-xs-6">
<labeled label="{$ label $}" for="{$ field.uid() $}">
<typed-field value="field" type="{$ field.getType() $}"></typed-field>
</labeled>
</div>
<div ng-if="!field.isAtomic()" class="col-xs-12">
<collapsible-group content="field" title="label"
additive="{$ field.isAdditive() $}" on-add="field.add()">
<div ng-class="field.isPlainStructure() ? 'col-xs-6' : 'col-xs-12'">
<typed-field value="field" type="{$ field.getType() $}"></typed-field>
</div>
</collapsible-group>
</div>
</div>
</div>
</panel>
</div>
<!-- YAML Panel -->
<div class="right-panel">
<div class="col-xs">
<div class="panel panel-default">
<div class="panel-body" ng-show="!wb.isGraphMode">
<pre>{$ wb.workbook.toYAML() $}</pre>
@ -93,9 +102,8 @@
</div>
</div>
<!-- page footer -->
<div class="two-panels">
<div class="full-width">
<div class="pull-right">
<div class="row">
<div class="col-xs end-xs">
<button ng-click="wb.discardWorkbook()" class="btn btn-default cancel">Cancel</button>
<button ng-click="wb.commitWorkbook()" class="btn btn-primary">
{$ wb.workbookID ? 'Modify' : 'Create' $}
@ -104,5 +112,4 @@
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -129,13 +129,6 @@ describe('workbook model logic', function() {
expect(json.workflows[workflowID].tasks[newID]).toBeDefined();
});
it('a task deletion works in conjunction with tasks logic', function() {
expect(getTask(taskID)).toBeDefined();
getTask(taskID).remove();
expect(getTask(taskID)).toBeUndefined();
});
});
describe("which start with the 'direct' workflow:", function() {

View File

@ -39,9 +39,21 @@
* retrieves a template by its name which is the same as model's type and renders it,
* recursive <typed-field></..>-s are possible.
* */
.directive('typedField', typedField);
.directive('typedField', typedField)
typedField.$inject = ['$compile', 'merlin.templates'];
.directive('labeled', labeled);
function labeled() {
return {
restrict: 'E',
templateUrl: '/static/merlin/templates/labeled.html',
transclude: true,
scope: {
label: '@',
for: '@'
}
};
}
function editable() {
return {
@ -100,6 +112,7 @@
};
}
showFocus.$inject = ['$timeout'];
function showFocus($timeout) {
return function(scope, element, attrs) {
// Unused variable created here due to rule 'ng_on_watch': 2
@ -114,7 +127,7 @@
};
}
function panel($parse) {
function panel() {
return {
restrict: 'E',
templateUrl: '/static/merlin/templates/collapsible-panel.html',
@ -122,9 +135,13 @@
scope: {
panel: '=content'
},
link: function(scope, element, attrs) {
scope.removable = $parse(attrs.removable)();
link: function(scope) {
if (angular.isDefined(scope.panel)) {
scope.isCollapsed = false;
if (angular.isFunction(scope.panel.title)) {
scope.editable = true;
}
}
}
};
}
@ -136,11 +153,15 @@
transclude: true,
scope: {
group: '=content',
title: '=',
onAdd: '&',
onRemove: '&'
},
link: function(scope, element, attrs) {
scope.isCollapsed = false;
if (angular.isFunction(scope.title)) {
scope.editable = true;
}
if ( attrs.onAdd && attrs.additive !== 'false' ) {
scope.additive = true;
}
@ -151,6 +172,7 @@
};
}
validatableWith.$inject = ['$parse'];
function validatableWith($parse) {
return {
restrict: 'A',
@ -186,6 +208,7 @@
};
}
typedField.$inject = ['$compile', 'merlin.templates'];
function typedField($compile, templates) {
return {
restrict: 'E',
@ -195,7 +218,7 @@
},
link: function(scope, element) {
templates.templateReady(scope.type).then(function(template) {
element.replaceWith($compile(template)(scope));
element.append($compile(template)(scope));
});
}
};

View File

@ -61,6 +61,24 @@
return this;
});
/* Html renderer helper. The main idea is that fields with simple (or plain)
structure (i.e. Atomics = string | number | text | boolean and list or
dictionary containing just Atomics) could be rendered in one column, while
fields with non plain structure should be rendered in two columns.
*/
var plainStructureMixin = Barricade.Blueprint.create(function() {
this.isPlainStructure = function() {
if (this.getType() == 'frozendict') {
return false;
}
if (!this.instanceof(Barricade.Arraylike) || !this.length()) {
return false;
}
return !this.get(0).instanceof(Barricade.Container);
};
return this;
});
var modelMixin = Barricade.Blueprint.create(function(type) {
var isValid = true;
var isValidatable = false;
@ -90,8 +108,12 @@
type = _type;
};
this.isAdditive = function() {
return this.instanceof(Barricade.Arraylike);
};
this.isAtomic = function() {
return ['number', 'string', 'text', 'choices'].indexOf(this.getType()) > -1;
return !this.instanceof(Barricade.Container);
};
this.title = function() {
var title = utils.getMeta(this, 'title');
@ -148,13 +170,8 @@
self.add = function() {
self.push(undefined, parameters);
};
self.getValues = function() {
return self.toArray();
};
self._getContents = function() {
return self.toArray();
};
meldGroup.call(self);
plainStructureMixin.call(self);
return self;
}
}, {'@type': Array});
@ -162,20 +179,10 @@
var frozendictModel = Barricade.ImmutableObject.extend({
create: function(json, parameters) {
var self = Barricade.ImmutableObject.create.call(this, json, parameters);
self.getKeys().forEach(function(key) {
utils.enhanceItemWithID(self.get(key), key);
});
modelMixin.call(self, 'frozendict');
self.getValues = function() {
return self._data;
};
self._getContents = function() {
return self.getKeys().map(function(key) {
return self.get(key);
});
};
meldGroup.call(self);
plainStructureMixin.call(self);
return self;
}
}, {'@type': Object});
@ -183,15 +190,14 @@
var dictionaryModel = Barricade.MutableObject.extend({
create: function(json, parameters) {
var self = Barricade.MutableObject.create.call(this, json, parameters);
var _items = [];
var _elClass = self._elementClass;
var baseKey = utils.getMeta(_elClass, 'baseKey') || 'key';
var baseName = utils.getMeta(_elClass, 'baseName') || utils.makeTitle(baseKey);
modelMixin.call(self, 'dictionary');
plainStructureMixin.call(self);
function makeCacheWrapper(container, key) {
var value = container.getByID(key);
function initKeyAccessor(value) {
value.keyValue = function () {
if ( arguments.length ) {
value.setID(arguments[0]);
@ -199,9 +205,16 @@
return value.getID();
}
};
return value;
}
self.each(function(key, value) {
initKeyAccessor(value);
}).on('change', function(op, index) {
if (op === 'add' || op === 'set') {
initKeyAccessor(self.get(index));
}
});
self.add = function(newID) {
var regexp = new RegExp('(' + baseKey + ')([0-9]+)');
var newValue;
@ -217,21 +230,11 @@
newValue = '';
}
self.push(newValue, utils.extend(self._parameters, {id: newID}));
_items.push(makeCacheWrapper(self, newID));
};
self.getValues = function() {
if ( !_items.length ) {
_items = self.toArray().map(function(value) {
return makeCacheWrapper(self, value.getID());
});
}
return _items;
};
self.empty = function() {
for ( var i = this._data.length; i > 0; i-- ) {
self.remove(i - 1);
}
_items = [];
};
self.resetKeys = function(keys) {
self.empty();
@ -239,17 +242,10 @@
self.push(undefined, {id: key});
});
};
self._getContents = function() {
return self.toArray();
};
self.removeItem = function(key) {
var pos = self.getPosByID(key);
self.remove(self.getPosByID(key));
_items.splice(pos, 1);
};
meldGroup.call(self);
// initialize cache with starting values
self.getValues();
return self;
}
}, {'@type': Object});

View File

@ -16,148 +16,187 @@
(function() {
angular
.module('merlin')
/* 'extractPanels' filter requires one argument which should be a function.
This function is applied to the top-level elements of the object and the
fields for which it returns a numeric value are grouped into the panels. More
precisely, each field yielding the same numeric value is put into the same panel.
Subclasses of Barricade.Container which don't yield a numeric value (and return
null, for example) become the entry points of a recursive application of above
algorithm, so eventually each field will be either:
* put into a panel (determinant returns numeric value)
* recursively scanned for more fields (is a container, no numeric value returned)
* or skipped completely (neither of above conditions is met).
Each returned panel implements at least .each() method (iterating through all key &
field pairs of a panel) which could be later consumed by 'extractFields' filter.
Filter results are cached, with each field explicitly put into a panel by determinant
(i.e. yielding a numeric value) adds its unique id to the caching key. This means that
the filter returns a new set of panels if the set of fields explicitly put into panels
changes - i.e. a value goes away or comes in into a set or replaced in place with
another value (any case is tracked by the unique field id).
*/
.filter('extractPanels', extractPanels)
.filter('extractRows', extractRows)
.filter('extractItems', extractItems);
.filter('extractFields', extractFields)
.filter('chunks', chunks);
extractPanels.$inject = ['merlin.utils'];
extractRows.$inject = ['merlin.utils'];
extractItems.$inject = ['merlin.utils'];
function extractPanels(utils) {
var panelProto = {
create: function(itemsOrContainer, id) {
if ( angular.isArray(itemsOrContainer) && !itemsOrContainer.length ) {
return null;
}
if ( angular.isArray(itemsOrContainer) ) {
this.items = itemsOrContainer;
this.id = itemsOrContainer.reduce(function(prevId, item) {
return item.uid() + prevId;
}, '');
create: function(enumerator, obj, context) {
this.$$obj = obj;
this.$$enumerator = enumerator;
this.removable = false;
if (this.$$obj) {
this.id = this.$$obj.uid();
this.$$objParent = context.container;
this.removable = this.$$objParent.instanceof(Barricade.Arraylike);
if (this.$$objParent.instanceof(Barricade.MutableObject)) {
this.title = function() {
if ( arguments.length ) {
obj.setID(arguments[0]);
} else {
this._barricadeContainer = itemsOrContainer;
this._barricadeId = id;
var barricadeObj = itemsOrContainer.getByID(id);
this.id = barricadeObj.uid();
this.items = barricadeObj.getKeys().map(function(key) {
return utils.enhanceItemWithID(barricadeObj.get(key), key);
return obj.getID();
}
};
} else if (this.$$objParent.instanceof(Barricade.ImmutableObject)) {
this.title = context.indexOrKey;
}
} else {
var id = '';
this.$$enumerator(function(key, item) {
id += item.uid();
});
this.removable = true;
this.id = id;
}
return this;
},
title: function() {
var newID;
if ( this._barricadeContainer ) {
if ( arguments.length ) {
newID = arguments[0];
this._barricadeContainer.getByID(this._barricadeId).setID(newID);
this._barricadeId = newID;
} else {
return this._barricadeId;
}
}
each: function(callback, comparator) {
this.$$enumerator.call(this.$$obj, callback, comparator);
},
remove: function() {
var container = this._barricadeContainer;
var pos = container.getPosByID(this._barricadeId);
container.remove(pos);
var index;
if (this.removable) {
index = this.$$objParent.toArray().indexOf(this.$$obj);
this.$$objParent.remove(index);
}
}
};
function isPanelsRoot(item) {
try {
// check for 'actions' and 'workflows' containers
return item.instanceof(Barricade.MutableObject);
}
catch(err) {
return false;
}
}
function extractPanelsRoot(items) {
return isPanelsRoot(items[0]) ? items[0] : null;
}
return _.memoize(function(container) {
var items = container._getContents();
return _.memoize(function(container, keyExtractor) {
var items = [];
var _data = {};
var panels = [];
utils.groupByMetaKey(items, 'panelIndex').forEach(function(items) {
var panelsRoot = extractPanelsRoot(items);
if ( panelsRoot ) {
panelsRoot.getIDs().forEach(function(id) {
panels.push(Object.create(panelProto).create(panelsRoot, id));
});
} else {
panels.push(Object.create(panelProto).create(items));
/* This function recursively applies determinant 'keyExtractor' function
to each container (given that the determinant doesn't return a numeric
value for it), starting from the top-level. Fields for which determinant
returns a numeric value, will be later placed into a panels (see docs for
'extractPanels' filter).
*/
function rec(container) {
container.each(function(indexOrKey, item) {
var groupingKey = keyExtractor(item, container);
if (angular.isNumber(groupingKey)) {
items.push(item);
_data[item.uid()] = {
groupingKey: groupingKey,
container: container,
indexOrKey: indexOrKey
};
} else if (item.instanceof(Barricade.Container)) {
rec(item);
}
});
}
// top-level entry-point of recursive descent
rec(container);
function extractKey(item) {
return angular.isDefined(item) && _data[item.uid()].groupingKey;
}
utils.groupByExtractedKey(items, extractKey).forEach(function(items) {
var parent, enumerator, obj, context;
if (items.length > 1 || !items[0].instanceof(Barricade.Container)) {
parent = _data[items[0].uid()].container;
// the enumerator function mimicking the behavior of built-in .each()
// method which aggregate panels do not have
enumerator = function(callback) {
items.forEach(function(item) {
if (_data[item.uid()].container === parent) {
callback(_data[item.uid()].indexOrKey, item);
}
});
};
} else {
obj = items[0];
enumerator = obj.each;
context = _data[obj.uid()];
}
panels.push(Object.create(panelProto).create(enumerator, obj, context));
});
return utils.condense(panels);
}, function(container) {
}, function(container, keyExtractor) {
var hash = '';
container.getKeys().map(function(key) {
var item = container.get(key);
if ( isPanelsRoot(item) ) {
item.getIDs().forEach(function(id) {
hash += item.getByID(id).uid();
});
} else {
function rec(container) {
container.each(function(indexOrKey, item) {
var groupingKey = keyExtractor(item, container);
if (angular.isNumber(groupingKey)) {
hash += item.uid();
} else if (item.instanceof(Barricade.Container)) {
rec(item);
}
});
}
rec(container);
return hash;
});
}
function extractRows(utils) {
function getItems(panelOrContainer) {
if ( panelOrContainer.items ) {
return panelOrContainer.items;
} else if ( panelOrContainer.getKeys ) {
return panelOrContainer.getKeys().map(function(key) {
return panelOrContainer.get(key);
});
} else {
return panelOrContainer.getIDs().map(function(id) {
return panelOrContainer.getByID(id);
});
}
}
return _.memoize(function(panel) {
var rowProto = {
create: function(items) {
this.id = items[0].uid();
this.index = items.row;
this.items = items.slice();
return this;
}
};
return utils.groupByMetaKey(getItems(panel), 'row').map(function(items) {
return Object.create(rowProto).create(items);
function extractFields() {
return _.memoize(function(container) {
var fields = {};
container.each(function(key, item) {
fields[key] = item;
});
return fields;
}, function(panel) {
var hash = '';
getItems(panel).forEach(function(item) {
panel.each(function(key, item) {
hash += item.uid();
});
return hash;
});
}
function extractItems(utils) {
return _.memoize(function(row) {
return row.items.sort(function(item1, item2) {
return utils.getMeta(item1, 'index') - utils.getMeta(item2, 'index');
});
}, function(row) {
function chunks() {
return _.memoize(function(fields, itemsPerChunk) {
var chunks = [];
var keys = Object.keys(fields);
var i, j, chunk;
itemsPerChunk = +itemsPerChunk;
if (!angular.isNumber(itemsPerChunk) || itemsPerChunk < 1) {
return chunks;
}
for (i = 0; i < keys.length; i++) {
chunk = {};
for (j = 0; j < itemsPerChunk; j++) {
chunk[keys[i]] = fields[keys[i]];
}
chunks.push(chunk);
}
return chunks;
}, function(fields) {
var hash = '';
row.items.forEach(function(item) {
hash += item.uid();
});
var key;
for (key in fields) {
if (fields.hasOwnProperty(key)) {
hash += fields[key].uid();
}
}
return hash;
});
}
})();

View File

@ -19,7 +19,7 @@
function fieldTemplates() {
return [
'dictionary', 'frozendict', 'list',
'string', 'text', 'group', 'number', 'choices'
'string', 'text', 'number', 'choices'
];
}

View File

@ -23,16 +23,16 @@
return 'id-' + idCounter;
}
function groupByMetaKey(sequence, metaKey, insertAtBeginning) {
function groupByExtractedKey(sequence, keyExtractor, insertAtBeginning) {
var newSequence = [];
var defaultBucket = [];
var index;
sequence.forEach(function(item) {
index = getMeta(item, metaKey);
index = keyExtractor(item);
if ( angular.isDefined(index) ) {
if ( !newSequence[index] ) {
newSequence[index] = [];
newSequence[index][metaKey] = index;
newSequence[index][keyExtractor()] = index;
}
newSequence[index].push(item);
} else {
@ -51,6 +51,17 @@
return newSequence;
}
function groupByMetaKey(sequence, metaKey, insertAtBeginning) {
function keyExtractor(item) {
if (angular.isDefined(item)) {
return getMeta(item, metaKey);
} else {
return metaKey;
}
}
return groupByExtractedKey(sequence, keyExtractor, insertAtBeginning);
}
function getMeta(item, key) {
if ( item ) {
var meta = item._schema['@meta'];
@ -103,6 +114,7 @@
getMeta: getMeta,
getNewId: getNewId,
groupByMetaKey: groupByMetaKey,
groupByExtractedKey: groupByExtractedKey,
makeTitle: makeTitle,
getNextIDSuffix: getNextIDSuffix,
enhanceItemWithID: enhanceItemWithID,

View File

@ -1,53 +1,8 @@
@import "/bootstrap/scss/bootstrap";
.two-panels {
@include make-row();
.left-panel {
@include make-xs-column(6);
}
.right-panel {
@include make-xs-column(6);
}
.full-width {
@include make-xs-column(12);
}
}
.two-columns {
@include make-row();
.left-column {
@include make-xs-column(6);
}
.right-column {
@include make-xs-column(6);
}
}
.three-columns {
@include make-row();
.left-column {
@include make-xs-column(5);
}
.right-column {
@include make-xs-column(5);
}
.both-columns {
@include make-xs-column(10);
}
.button-column {
@include make-xs-column(2);
}
}
.panel-default.merlin-panel {
.panel-heading {
color: inherit;
background-color: inherit;
border: none;
padding-left: 20px;
}
.panel-body {
padding-left: 20px;
}
textarea {
resize: vertical;
@ -64,19 +19,15 @@ editable {
}
.section {
.form-group {
padding-left: 15px;
}
.section {
margin-left: 15px;
h5 {
font-weight: bold;
}
.section-heading {
a {
padding-left: 5px;
text-decoration: none;
color: black;
}
h5 {
font-weight: bold;
}
}
@ -93,28 +44,8 @@ editable {
}
}
.popover-content > button {
margin: 5px;
float: right;
}
.popover.right {
width: 200px;
}
.dictionary .add-btn {
margin-top: 26px;
}
.list .add-btn {
margin-top: 2px;
&.varlist-1st-row {
margin-top: 26px;
}
}
.right-column .form-group {
padding-left: 0;
margin-bottom: 15px;
}
.well .panel-body pre {
@ -124,12 +55,10 @@ editable {
}
i.fa-times-circle {
padding-right: 10px;
.section .section & {
font-weight: bold;
margin-top: 10px;
margin-bottom: 0;
font-size: 15px;
color: inherit;
.section .section .section-heading & {
margin-top: 7px;
}
}
};

View File

@ -1,18 +1,18 @@
<div class="section">
<div class="section-heading three-columns">
<div class="both-columns">
<div class="section-heading row">
<div class="col-xs-10">
<h5><a ng-click="isCollapsed = !isCollapsed" class="collapse-entries" href="">
<i class="fa" ng-class="isCollapsed ? 'fa-plus-square-o' : 'fa-minus-square-o'"></i></a>
<editable ng-if="removable" ng-model="group.title"
<editable ng-if="editable" ng-model="title"
ng-model-options="{getterSetter: true}"></editable>
<span ng-if="!removable">{$ group.title() $}</span>
<span ng-if="!editable">{$ ::title $}</span>
</h5>
</div>
<div ng-if="additive" class="add-btn button-column add-entry">
<div ng-if="additive" class="add-btn col-xs add-entry">
<button class="btn btn-default btn-sm pull-right" ng-click="onAdd()">
<i class="fa fa-plus"></i></button>
</div>
<div ng-if="removable" class="add-btn button-column remove-entry">
<div ng-if="removable" class="add-btn col-xs remove-entry">
<a href="" ng-click="onRemove()">
<i class="fa fa-times-circle pull-right"></i></a>
</div>

View File

@ -1,9 +1,11 @@
<div class="panel panel-default merlin-panel">
<div class="panel-heading" ng-show="panel.title()">
<div class="panel-heading" ng-show="panel.title">
<h4 class="panel-title">
<a ng-click="isCollapsed = !isCollapsed" href="">
<i class="fa fa-lg" ng-class="isCollapsed ? 'fa-caret-right' : 'fa-caret-down'"></i></a>
<editable ng-model="panel.title" ng-model-options="{getterSetter: true}"></editable>
<editable ng-if="editable" ng-model="panel.title"
ng-model-options="{getterSetter: true}"></editable>
<span ng-if="!editable">{$ ::panel.title $}</span>
<a href="" ng-show="panel.removable" ng-click="panel.remove()">
<i class="fa fa-times-circle pull-right"></i></a>
</h4>

View File

@ -1,16 +1,13 @@
<div class="form-group">
<label for="elem-{$ $id $}">{$ value.title() $}</label>
<select ng-if="value.isDropDown()"
id="elem-{$ $id $}" class="form-control"
<select ng-if="value.isDropDown()"
id="{$ value.uid() $}" class="form-control"
ng-model="value.value" ng-model-options="{getterSetter: true}">
<option ng-repeat="option in value.getValues()"
value="{$ option $}"
ng-selected="value.get() == option">{$ value.getLabel(option) $}</option>
</select>
<input ng-if="!value.isDropDown()"
type="text" class="form-control" id="elem-{$ $id $}"
</select>
<input ng-if="!value.isDropDown()"
type="text" class="form-control" id="{$ value.uid() $}"
ng-model="value.value" ng-model-options="{ getterSetter: true }"
validatable-with="value" typeahead-editable="true"
typeahead="option for option in value.getValues() | filter:$viewValue">
<div ng-show="error" class="alert alert-danger">{$ error $}</div>
</div>
<div ng-show="error" class="alert alert-danger">{$ error $}</div>

View File

@ -1,19 +1,34 @@
<collapsible-group content="value" on-add="value.add()">
<div class="three-columns" ng-repeat="subvalue in value.getValues() track by subvalue.keyValue()">
<div class="left-column">
<div class="form-group">
<label for="elem-{$ $id $}.{$ subvalue.uid() $}">
<editable ng-model="subvalue.keyValue" ng-model-options="{getterSetter: true}"></editable>
<div class="row bottom-xs dictionary">
<div ng-class="value.inline ? 'col-xs-10' : 'col-xs-12'">
<div ng-repeat="(key, field) in value | extractFields track by field.uid()">
<div ng-if="field.isAtomic()" class="form-group">
<label for="{$ field.uid() $}">
<editable ng-model="field.keyValue" ng-model-options="{getterSetter: true}"></editable>
</label>
<div class="input-group">
<input id="elem-{$ $id $}.{$ subvalue.uid() $}" type="text" class="form-control"
ng-model="subvalue.value" ng-model-options="{ getterSetter: true }">
<typed-field id="{$ field.uid() $}" value="field" type="{$ field.getType() $}"></typed-field>
<span class="input-group-btn">
<button class="btn btn-default fa fa-minus-circle" type="button"
ng-click="value.removeItem(subvalue.keyValue())"></button>
<button class="btn btn-default" ng-click="value.removeItem(field.keyValue())">
<i class="fa fa-minus-circle"></i>
</button>
</span>
</div>
</div>
<div ng-if="!field.isAtomic()">
<collapsible-group ng-if="!field.inline" content="field"
class="col-xs-12"
title="field.keyValue"
on-remove="value.removeItem(field.keyValue())"
additive="{$ field.isAdditive() $}" on-add="field.add()">
<typed-field value="field" type="{$ field.getType() $}"></typed-field>
</collapsible-group>
<typed-field ng-if="field.inline"
value="field" type="{$ field.getType() $}"></typed-field>
</div>
</div>
</collapsible-group>
</div>
<div ng-if="value.inline" class="col-xs add-entry" style="margin-bottom: 15px">
<button class="btn btn-default btn-sm pull-right" ng-click="value.add()">
<i class="fa fa-plus"></i></button>
</div>
</div>

View File

@ -1,15 +1,24 @@
<collapsible-group content="value">
<div ng-repeat="row in value | extractRows track by row.id">
<div ng-class="{'three-columns': row.index !== undefined}">
<div ng-repeat="item in row | extractItems track by item.uid()"
ng-class="{'right-column': item.isAtomic() && $odd, 'left-column': item.isAtomic() && $even}">
<div class="form-group">
<label for="elem-{$ $id $}.{$ item.uid() $}">{$ item.title() $}</label>
<input type="text" class="form-control" id="elem-{$ $id $}.{$ item.uid() $}" ng-model="item.value"
ng-model-options="{getterSetter: true}">
<div class="frozendict">
<div ng-repeat="row in value | extractFields | chunks:2 track by $index">
<div ng-repeat="(key, field) in row track by field.uid()">
<div ng-if="field.isAtomic()" class="col-xs-6">
<labeled label="{$ key $}" for="{$ field.uid() $}">
<typed-field value="field" type="{$ field.getType() $}"></typed-field>
</labeled>
</div>
<div class="clearfix" ng-if="$odd"></div>
<div ng-if="!field.isAtomic()">
<collapsible-group ng-if="!field.inline" class="col-xs-12"
content="field" title="key"
additive="{$ field.isAdditive() $}" on-add="field.add()">
<div ng-class="field.isPlainStructure() ? 'col-xs-6' : 'col-xs-12'">
<typed-field value="field" type="{$ field.getType() $}"></typed-field>
</div>
</collapsible-group>
<labeled ng-if="field.inline" class="col-xs-6"
label="{$ key $}" for="{$ field.uid() $}">
<typed-field value="field" type="{$ field.getType() $}"></typed-field>
</labeled>
</div>
</div>
</div>
</collapsible-group>
</div>

View File

@ -1,13 +0,0 @@
<collapsible-group content="value" additive="{$ value.isAdditive() $}"
on-add="value.add()"
removable="{$ value.isRemovable() $}" on-remove="value.remove()">
<div ng-repeat="row in value | extractRows track by row.id">
<div ng-class="{'three-columns': row.index !== undefined }">
<div ng-repeat="item in row | extractItems track by item.id"
ng-class="{'right-column': item.isAtomic() && $odd, 'left-column': item.isAtomic() && $even}">
<typed-field value="item" type="{$ item.getType() $}"></typed-field>
<div class="clearfix" ng-if="$odd"></div>
</div>
</div>
</div>
</collapsible-group>

View File

@ -1,9 +1,9 @@
<collapsible-group content="value" on-add="value.add()">
<div class="three-columns">
<div class="left-column">
<div class="form-group" ng-repeat="subItem in value.getValues() track by $index">
<div class="row bottom-xs list">
<div ng-class="value.inline ? 'col-xs-10' : 'col-xs-12'">
<div ng-repeat="(index, field) in value | extractFields track by field.uid()">
<div ng-if="field.isAtomic()" class="form-group">
<div class="input-group">
<input type="text" class="form-control" ng-model="subItem.value" ng-model-options="{ getterSetter: true }">
<typed-field value="field" type="{$ field.getType() $}"></typed-field>
<span class="input-group-btn">
<button class="btn btn-default" ng-click="value.remove($index)">
<i class="fa fa-minus-circle"></i>
@ -11,6 +11,13 @@
</span>
</div>
</div>
<div ng-if="!field.isAtomic()">
<typed-field value="field" type="{$ field.getType() $}"></typed-field>
</div>
</div>
</collapsible-group>
</div>
<div ng-if="value.inline" class="col-xs add-btn">
<button class="btn btn-default btn-sm pull-right" ng-click="value.add()">
<i class="fa fa-plus"></i></button>
</div>
</div>

View File

@ -1,8 +1,4 @@
<div class="form-group">
<pre>{$ value $}, {$ value.title() $}</pre>
<label for="elem-{$ $id $}">{$ value.title() $}</label>
<input type="number" class="form-control" id="elem-{$ $id $}"
<input type="number" class="form-control" id="{$ value.uid() $}"
ng-model="value.value" ng-model-options="{ getterSetter: true }"
validatable-with="value">
<div ng-show="error" class="alert alert-danger">{$ error $}</div>
</div>
<div ng-show="error" class="alert alert-danger">{$ error $}</div>

View File

@ -1,7 +1,4 @@
<div class="form-group">
<label for="elem-{$ $id $}">{$ value.title() $}</label>
<input type="text" class="form-control" id="elem-{$ $id $}"
<input type="text" class="form-control" id="{$ value.uid() $}"
ng-model="value.value" ng-model-options="{ getterSetter: true }"
validatable-with="value">
<div ng-show="error" class="alert alert-danger">{$ error $}</div>
</div>
<div ng-show="error" class="alert alert-danger">{$ error $}</div>

View File

@ -1,7 +1,4 @@
<div class="form-group">
<label for="elem-{$ $id $}">{$ value.title() $}</label>
<textarea class="form-control" id="elem-{$ $id $}"
<textarea class="form-control" id="{$ value.uid() $}"
ng-model="value.value" ng-model-options="{ getterSetter: true }"
validatable-with="value"></textarea>
<div ng-show="error" class="alert alert-danger">{$ error $}</div>
</div>
<div ng-show="error" class="alert alert-danger">{$ error $}</div>

View File

@ -0,0 +1,4 @@
<div class="form-group">
<label for="{$ for $}">{$ label $}</label>
<div ng-transclude></div>
</div>

View File

@ -66,7 +66,7 @@ describe('merlin directives', function() {
return element;
}
it('shows panel heading when and only when its title() is not false', function() {
it('shows panel heading when and only when its title is defined', function() {
var title = 'My Panel',
element1, element2;

File diff suppressed because it is too large Load Diff

View File

@ -47,41 +47,18 @@ describe('merlin models:', function() {
return value;
}
function getCacheIDs() {
return dictObj.getValues().map(function(item) {
return item.getID();
});
}
describe('getValues() method', function() {
it('caching works from the very beginning', function() {
expect(getCacheIDs()).toEqual(['id1', 'id2']);
});
it('keyValue() getter/setter can be used from the start', function() {
var value = getValueFromCache('id1');
expect(value.keyValue()).toBe('id1');
value.keyValue('id3');
expect(value.keyValue()).toBe('id3');
expect(dictObj.getByID('id3')).toBeDefined();
});
});
describe('add() method', function() {
it('adds an empty value with given key', function() {
dictObj.add('id3');
expect(dictObj.getByID('id3').get()).toBe('');
expect(getCacheIDs()).toEqual(['id1', 'id2', 'id3']);
});
it('keyValue() getter/setter can be used for added values', function() {
var value;
dictObj.add('id3');
value = getValueFromCache('id3');
value = dictObj.getByID('id3');
expect(value.keyValue()).toBe('id3');
@ -112,31 +89,28 @@ describe('merlin models:', function() {
});
describe('empty() method', function() {
it('removes all entries in model and in cache', function() {
it('removes all entries in model', function() {
dictObj.empty();
expect(dictObj.getIDs().length).toBe(0);
expect(dictObj.getValues().length).toBe(0);
})
});
describe('resetKeys() method', function() {
it('re-sets dictionary contents to given keys, cache included', function() {
it('re-sets dictionary contents to given keys', function() {
dictObj.resetKeys(['key1', 'key2']);
expect(dictObj.getIDs()).toEqual(['key1', 'key2']);
expect(dictObj.getByID('key1').get()).toBe('');
expect(dictObj.getByID('key2').get()).toBe('');
expect(getCacheIDs()).toEqual(['key1', 'key2']);
})
});
describe('removeItem() method', function() {
it('removes dictionary entry by key from model and cache', function() {
it('removes dictionary entry by key from model', function() {
dictObj.removeItem('id1');
expect(dictObj.getByID('id1')).toBeUndefined();
expect(getCacheIDs()).toEqual(['id2']);
})
});