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-moment": "0.9.0",
"angular-cache": "3.2.5", "angular-cache": "3.2.5",
"js-yaml": "3.2.7", "js-yaml": "3.2.7",
"underscore": "1.8.3" "underscore": "1.8.3",
"flexboxgrid": "6.2.0"
}, },
"devDependencies": { "devDependencies": {
"angular-mocks": "1.3.10", "angular-mocks": "1.3.10",

View File

@ -12,7 +12,7 @@
function initModule(templates) { function initModule(templates) {
templates.prefetch('/static/mistral/templates/fields/', 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) { function getNextIDSuffix(container, regexp) {
var max = Math.max.apply(Math, container.getIDs().map(function(id) { var max = Math.max.apply(Math, container.getIDs().map(function(id) {
var match = regexp.exec(id); var match = regexp.exec(id);

View File

@ -18,11 +18,15 @@
if ( angular.isUndefined(json) || type === String ) { if ( angular.isUndefined(json) || type === String ) {
return fields.string.create(json, parameters); return fields.string.create(json, parameters);
} else if ( type === Array ) { } else if ( type === Array ) {
return fields.list.extend({}, { return fields.list.extend({
inline: true
}, {
'*': {'@class': fields.string} '*': {'@class': fields.string}
}).create(json, parameters); }).create(json, parameters);
} else if ( type === Object ) { } else if ( type === Object ) {
return fields.dictionary.extend({}, { return fields.dictionary.extend({
inline: true
}, {
'?': {'@class': fields.string} '?': {'@class': fields.string}
}).create(json, parameters); }).create(json, parameters);
} }
@ -31,7 +35,6 @@
models.varlist = fields.list.extend({ models.varlist = fields.list.extend({
create: function(json, parameters) { create: function(json, parameters) {
var self = fields.list.create.call(this, json, parameters); var self = fields.list.create.call(this, json, parameters);
self.setType('varlist');
self.on('childChange', function(child, op) { self.on('childChange', function(child, op) {
if ( op == 'empty' ) { if ( op == 'empty' ) {
self.each(function(index, item) { self.each(function(index, item) {
@ -48,6 +51,7 @@
'@class': fields.frozendict.extend({ '@class': fields.frozendict.extend({
create: function(json, parameters) { create: function(json, parameters) {
var self = fields.frozendict.create.call(this, json, parameters); var self = fields.frozendict.create.call(this, json, parameters);
self.isAtomic = function() { return false; };
self.on('childChange', function(child) { self.on('childChange', function(child) {
if ( child.instanceof(Barricade.Enumerated) ) { // type change if ( child.instanceof(Barricade.Enumerated) ) { // type change
var value = self.get('value'); var value = self.get('value');
@ -87,23 +91,23 @@
} }
}); });
models.yaqllist = fields.list.extend({ models.YAQLField = fields.frozendict.extend({
create: function(json, parameters) { create: function(json, parameters) {
var self = fields.list.create.call(this, json, parameters); var self = fields.frozendict.create.call(this, json, parameters);
self.setType('yaqllist'); self.setType('yaqlfield');
return self; return self;
} }
}, { }, {
'*': {
'@class': fields.frozendict.extend({}, {
'yaql': { 'yaql': {
'@class': fields.string '@class': fields.string
}, },
'action': { 'action': {
'@class': fields.string '@class': fields.string
} }
}) });
}
models.yaqllist = fields.list.extend({}, {
'*': {'@class': models.YAQLField}
}); });
models.Action = fields.frozendict.extend({ models.Action = fields.frozendict.extend({
@ -135,8 +139,7 @@
} }
}, { }, {
'@meta': { '@meta': {
'index': 1, 'index': 1
'row': 0
} }
}) })
}, },
@ -144,18 +147,24 @@
'@class': fields.dictionary.extend({ '@class': fields.dictionary.extend({
create: function(json, parameters) { create: function(json, parameters) {
var self = fields.dictionary.create.call(this, json, parameters); var self = fields.dictionary.create.call(this, json, parameters);
self.isAdditive = function() { return false; };
self.setType('frozendict'); self.setType('frozendict');
return self; 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, '@required': false,
'?': { '?': {'@class': fields.string},
'@class': fields.string.extend({}, {
'@meta': {
'row': 0
}
})
},
'@meta': { '@meta': {
'index': 2, 'index': 2,
'title': 'Base Input' 'title': 'Base Input'
@ -189,9 +198,6 @@
}); });
return self; return self;
}, },
remove: function() {
this.emit('change', 'taskRemove', this.getID());
},
_getPrettyJSON: function() { _getPrettyJSON: function() {
var json = fields.frozendict._getPrettyJSON.apply(this, arguments); var json = fields.frozendict._getPrettyJSON.apply(this, arguments);
delete json.type; delete json.type;
@ -200,10 +206,7 @@
}, { }, {
'@meta': { '@meta': {
'baseKey': 'task', 'baseKey': 'task',
'baseName': 'Task ', 'baseName': 'Task '
'group': true,
'additive': false,
'removable': true
}, },
'type': { 'type': {
'@class': fields.string.extend({}, { '@class': fields.string.extend({}, {
@ -214,16 +217,14 @@
}], }],
'@default': 'action', '@default': 'action',
'@meta': { '@meta': {
'index': 0, 'index': 0
'row': 0
} }
}) })
}, },
'description': { 'description': {
'@class': fields.text.extend({}, { '@class': fields.text.extend({}, {
'@meta': { '@meta': {
'index': 2, 'index': 2
'row': 1
} }
}) })
}, },
@ -268,7 +269,6 @@
'@required': false, '@required': false,
'@meta': { '@meta': {
'index': 0, 'index': 0,
'row': 0,
'title': 'Wait before' 'title': 'Wait before'
} }
}) })
@ -278,7 +278,6 @@
'@required': false, '@required': false,
'@meta': { '@meta': {
'index': 1, 'index': 1,
'row': 0,
'title': 'Wait after' 'title': 'Wait after'
} }
}) })
@ -287,8 +286,7 @@
'@class': fields.number.extend({}, { '@class': fields.number.extend({}, {
'@required': false, '@required': false,
'@meta': { '@meta': {
'index': 2, 'index': 2
'row': 1
} }
}) })
}, },
@ -297,7 +295,6 @@
'@required': false, '@required': false,
'@meta': { '@meta': {
'index': 3, 'index': 3,
'row': 2,
'title': 'Retry count' 'title': 'Retry count'
} }
}) })
@ -307,7 +304,6 @@
'@required': false, '@required': false,
'@meta': { '@meta': {
'index': 4, 'index': 4,
'row': 2,
'title': 'Retry delay' 'title': 'Retry delay'
} }
}) })
@ -317,7 +313,6 @@
'@required': false, '@required': false,
'@meta': { '@meta': {
'index': 5, 'index': 5,
'row': 3,
'title': 'Retry break on' 'title': 'Retry break on'
} }
}) })
@ -330,7 +325,6 @@
'requires': { 'requires': {
'@class': fields.string.extend({}, { '@class': fields.string.extend({}, {
'@meta': { '@meta': {
'row': 2,
'index': 3 'index': 3
} }
}) })
@ -386,7 +380,6 @@
} }
}, { }, {
'@meta': { '@meta': {
'row': 0,
'index': 1 'index': 1
} }
}) })
@ -407,7 +400,6 @@
} }
}, { }, {
'@meta': { '@meta': {
'row': 0,
'index': 1 'index': 1
} }
}) })
@ -446,8 +438,7 @@
'@enum': ['reverse', 'direct'], '@enum': ['reverse', 'direct'],
'@default': 'direct', '@default': 'direct',
'@meta': { '@meta': {
'index': 1, 'index': 1
'row': 0
} }
}) })
}, },
@ -485,16 +476,13 @@
var taskData = child.toJSON(); var taskData = child.toJSON();
params.id = taskId; params.id = taskId;
self.set(taskPos, TaskFactory(taskData, params)); self.set(taskPos, TaskFactory(taskData, params));
} else if ( op === 'taskRemove' ) {
self.removeItem(arg);
} }
}); });
return self; return self;
} }
}, { }, {
'@meta': { '@meta': {
'index': 5, 'index': 5
'group': true
}, },
'?': { '?': {
'@class': models.Task, '@class': models.Task,
@ -511,9 +499,7 @@
'@class': fields.frozendict.extend({}, { '@class': fields.frozendict.extend({}, {
'@required': false, '@required': false,
'@meta': { '@meta': {
'index': 4, 'index': 4
'group': true,
'additive': false
}, },
'on-error': { 'on-error': {
'@class': models.yaqllist.extend({}, { '@class': models.yaqllist.extend({}, {
@ -557,8 +543,7 @@
models.Actions = fields.dictionary.extend({}, { models.Actions = fields.dictionary.extend({}, {
'@required': false, '@required': false,
'@meta': { '@meta': {
'index': 3, 'index': 3
'panelIndex': 1
}, },
'?': { '?': {
'@class': models.Action '@class': models.Action
@ -583,8 +568,7 @@
} }
}, { }, {
'@meta': { '@meta': {
'index': 4, 'index': 4
'panelIndex': 2
}, },
'?': { '?': {
'@class': models.Workflow, '@class': models.Workflow,
@ -601,9 +585,7 @@
'@class': fields.string.extend({}, { '@class': fields.string.extend({}, {
'@enum': ['2.0'], '@enum': ['2.0'],
'@meta': { '@meta': {
'index': 2, 'index': 2
'panelIndex': 0,
'row': 1
}, },
'@default': '2.0' '@default': '2.0'
}) })
@ -611,9 +593,7 @@
'name': { 'name': {
'@class': fields.string.extend({}, { '@class': fields.string.extend({}, {
'@meta': { '@meta': {
'index': 0, 'index': 0
'panelIndex': 0,
'row': 0
}, },
'@constraints': [ '@constraints': [
function(value) { function(value) {
@ -625,9 +605,7 @@
'description': { 'description': {
'@class': fields.text.extend({}, { '@class': fields.text.extend({}, {
'@meta': { '@meta': {
'index': 1, 'index': 1
'panelIndex': 0,
'row': 0
}, },
'@required': false '@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 %} {% compress css %}
<link href='{{ STATIC_URL }}merlin/scss/merlin.scss' type='text/scss' media='screen' rel='stylesheet' /> <link href='{{ STATIC_URL }}merlin/scss/merlin.scss' type='text/scss' media='screen' rel='stylesheet' />
{% endcompress %} {% endcompress %}
<link href='{{ STATIC_URL }}merlin/libs/flexboxgrid/dist/flexboxgrid.css' type='text/css' media='screen' rel='stylesheet' />
{% block merlin-css %}{% endblock %} {% block merlin-css %}{% endblock %}
{% endblock %} {% endblock %}
{% block main %} {% block main %}
<h3>Create Workbook</h3> <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 }}')"> ng-init="wb.init({{ id|default:'undefined' }}, '{{ yaml }}', '{{ commit_url }}', '{{ discard_url }}')">
<div class="well"> <div class="well">
<div class="two-panels"> <div class="row">
<div class="left-panel"> <div class="col-xs row">
<div class="pull-left"> <div class="col-xs start-xs">
<h4><strong>{$ wb.workbook.get('name') $}</strong></h4> <h4><strong>{$ wb.workbook.get('name') $}</strong></h4>
</div> </div>
<div class="pull-right"> <div class="col-xs end-xs">
<div class="table-actions clearfix"> <div class="table-actions">
<button ng-click="wb.addAction()" class="btn btn-default btn-sm"> <button ng-click="wb.addAction()" class="btn btn-default btn-sm">
<span class="fa fa-plus">Add Action</span></button> <span class="fa fa-plus">Add Action</span></button>
<button ng-click="wb.addWorkflow()" class="btn btn-default btn-sm"> <button ng-click="wb.addWorkflow()" class="btn btn-default btn-sm">
@ -55,8 +56,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="right-panel"> <div class="col-xs end-xs">
<div class="btn-group btn-toggle pull-right"> <div class="btn-group btn-toggle">
<button ng-click="wb.isGraphMode = true" class="btn btn-sm" <button ng-click="wb.isGraphMode = true" class="btn btn-sm"
ng-class="wb.isGraphMode ? 'active btn-primary' : 'btn-default'">Graph</button> ng-class="wb.isGraphMode ? 'active btn-primary' : 'btn-default'">Graph</button>
<button ng-click="wb.isGraphMode = false" class="btn btn-sm" <button ng-click="wb.isGraphMode = false" class="btn btn-sm"
@ -65,23 +66,31 @@
</div> </div>
</div> </div>
<!-- Data panel start --> <!-- Data panel start -->
<div class="two-panels"> <div class="row">
<div class="left-panel"> <div class="col-xs">
<panel ng-repeat="panel in wb.workbook | extractPanels track by panel.id" <panel ng-repeat="panel in wb.workbook | extractPanels:wb.keyExtractor track by panel.id"
content="panel"> content="panel">
<div ng-repeat="row in panel | extractRows track by row.id"> <div ng-repeat="row in panel | extractFields | chunks:2 track by $index">
<div ng-class="{'two-columns': row.index !== undefined }"> <div ng-repeat="(label, field) in row track by field.uid()">
<div ng-repeat="item in row | extractItems track by item.id" <div ng-if="field.isAtomic()" class="col-xs-6">
ng-class="{'right-column': item.isAtomic() && $odd, 'left-column': item.isAtomic() && $even}"> <labeled label="{$ label $}" for="{$ field.uid() $}">
<typed-field value="item" type="{$ item.getType() $}"></typed-field> <typed-field value="field" type="{$ field.getType() $}"></typed-field>
<div class="clearfix" ng-if="$odd"></div> </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> </div>
</div> </div>
</panel> </panel>
</div> </div>
<!-- YAML Panel --> <!-- YAML Panel -->
<div class="right-panel"> <div class="col-xs">
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-body" ng-show="!wb.isGraphMode"> <div class="panel-body" ng-show="!wb.isGraphMode">
<pre>{$ wb.workbook.toYAML() $}</pre> <pre>{$ wb.workbook.toYAML() $}</pre>
@ -93,9 +102,8 @@
</div> </div>
</div> </div>
<!-- page footer --> <!-- page footer -->
<div class="two-panels"> <div class="row">
<div class="full-width"> <div class="col-xs end-xs">
<div class="pull-right">
<button ng-click="wb.discardWorkbook()" class="btn btn-default cancel">Cancel</button> <button ng-click="wb.discardWorkbook()" class="btn btn-default cancel">Cancel</button>
<button ng-click="wb.commitWorkbook()" class="btn btn-primary"> <button ng-click="wb.commitWorkbook()" class="btn btn-primary">
{$ wb.workbookID ? 'Modify' : 'Create' $} {$ wb.workbookID ? 'Modify' : 'Create' $}
@ -104,5 +112,4 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{% endblock %} {% endblock %}

View File

@ -129,13 +129,6 @@ describe('workbook model logic', function() {
expect(json.workflows[workflowID].tasks[newID]).toBeDefined(); 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() { 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, * retrieves a template by its name which is the same as model's type and renders it,
* recursive <typed-field></..>-s are possible. * 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() { function editable() {
return { return {
@ -100,6 +112,7 @@
}; };
} }
showFocus.$inject = ['$timeout'];
function showFocus($timeout) { function showFocus($timeout) {
return function(scope, element, attrs) { return function(scope, element, attrs) {
// Unused variable created here due to rule 'ng_on_watch': 2 // Unused variable created here due to rule 'ng_on_watch': 2
@ -114,7 +127,7 @@
}; };
} }
function panel($parse) { function panel() {
return { return {
restrict: 'E', restrict: 'E',
templateUrl: '/static/merlin/templates/collapsible-panel.html', templateUrl: '/static/merlin/templates/collapsible-panel.html',
@ -122,9 +135,13 @@
scope: { scope: {
panel: '=content' panel: '=content'
}, },
link: function(scope, element, attrs) { link: function(scope) {
scope.removable = $parse(attrs.removable)(); if (angular.isDefined(scope.panel)) {
scope.isCollapsed = false; scope.isCollapsed = false;
if (angular.isFunction(scope.panel.title)) {
scope.editable = true;
}
}
} }
}; };
} }
@ -136,11 +153,15 @@
transclude: true, transclude: true,
scope: { scope: {
group: '=content', group: '=content',
title: '=',
onAdd: '&', onAdd: '&',
onRemove: '&' onRemove: '&'
}, },
link: function(scope, element, attrs) { link: function(scope, element, attrs) {
scope.isCollapsed = false; scope.isCollapsed = false;
if (angular.isFunction(scope.title)) {
scope.editable = true;
}
if ( attrs.onAdd && attrs.additive !== 'false' ) { if ( attrs.onAdd && attrs.additive !== 'false' ) {
scope.additive = true; scope.additive = true;
} }
@ -151,6 +172,7 @@
}; };
} }
validatableWith.$inject = ['$parse'];
function validatableWith($parse) { function validatableWith($parse) {
return { return {
restrict: 'A', restrict: 'A',
@ -186,6 +208,7 @@
}; };
} }
typedField.$inject = ['$compile', 'merlin.templates'];
function typedField($compile, templates) { function typedField($compile, templates) {
return { return {
restrict: 'E', restrict: 'E',
@ -195,7 +218,7 @@
}, },
link: function(scope, element) { link: function(scope, element) {
templates.templateReady(scope.type).then(function(template) { 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; 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 modelMixin = Barricade.Blueprint.create(function(type) {
var isValid = true; var isValid = true;
var isValidatable = false; var isValidatable = false;
@ -90,8 +108,12 @@
type = _type; type = _type;
}; };
this.isAdditive = function() {
return this.instanceof(Barricade.Arraylike);
};
this.isAtomic = function() { this.isAtomic = function() {
return ['number', 'string', 'text', 'choices'].indexOf(this.getType()) > -1; return !this.instanceof(Barricade.Container);
}; };
this.title = function() { this.title = function() {
var title = utils.getMeta(this, 'title'); var title = utils.getMeta(this, 'title');
@ -148,13 +170,8 @@
self.add = function() { self.add = function() {
self.push(undefined, parameters); self.push(undefined, parameters);
}; };
self.getValues = function() {
return self.toArray();
};
self._getContents = function() {
return self.toArray();
};
meldGroup.call(self); meldGroup.call(self);
plainStructureMixin.call(self);
return self; return self;
} }
}, {'@type': Array}); }, {'@type': Array});
@ -162,20 +179,10 @@
var frozendictModel = Barricade.ImmutableObject.extend({ var frozendictModel = Barricade.ImmutableObject.extend({
create: function(json, parameters) { create: function(json, parameters) {
var self = Barricade.ImmutableObject.create.call(this, 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'); 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); meldGroup.call(self);
plainStructureMixin.call(self);
return self; return self;
} }
}, {'@type': Object}); }, {'@type': Object});
@ -183,15 +190,14 @@
var dictionaryModel = Barricade.MutableObject.extend({ var dictionaryModel = Barricade.MutableObject.extend({
create: function(json, parameters) { create: function(json, parameters) {
var self = Barricade.MutableObject.create.call(this, json, parameters); var self = Barricade.MutableObject.create.call(this, json, parameters);
var _items = [];
var _elClass = self._elementClass; var _elClass = self._elementClass;
var baseKey = utils.getMeta(_elClass, 'baseKey') || 'key'; var baseKey = utils.getMeta(_elClass, 'baseKey') || 'key';
var baseName = utils.getMeta(_elClass, 'baseName') || utils.makeTitle(baseKey); var baseName = utils.getMeta(_elClass, 'baseName') || utils.makeTitle(baseKey);
modelMixin.call(self, 'dictionary'); modelMixin.call(self, 'dictionary');
plainStructureMixin.call(self);
function makeCacheWrapper(container, key) { function initKeyAccessor(value) {
var value = container.getByID(key);
value.keyValue = function () { value.keyValue = function () {
if ( arguments.length ) { if ( arguments.length ) {
value.setID(arguments[0]); value.setID(arguments[0]);
@ -199,9 +205,16 @@
return value.getID(); 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) { self.add = function(newID) {
var regexp = new RegExp('(' + baseKey + ')([0-9]+)'); var regexp = new RegExp('(' + baseKey + ')([0-9]+)');
var newValue; var newValue;
@ -217,21 +230,11 @@
newValue = ''; newValue = '';
} }
self.push(newValue, utils.extend(self._parameters, {id: newID})); 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() { self.empty = function() {
for ( var i = this._data.length; i > 0; i-- ) { for ( var i = this._data.length; i > 0; i-- ) {
self.remove(i - 1); self.remove(i - 1);
} }
_items = [];
}; };
self.resetKeys = function(keys) { self.resetKeys = function(keys) {
self.empty(); self.empty();
@ -239,17 +242,10 @@
self.push(undefined, {id: key}); self.push(undefined, {id: key});
}); });
}; };
self._getContents = function() {
return self.toArray();
};
self.removeItem = function(key) { self.removeItem = function(key) {
var pos = self.getPosByID(key);
self.remove(self.getPosByID(key)); self.remove(self.getPosByID(key));
_items.splice(pos, 1);
}; };
meldGroup.call(self); meldGroup.call(self);
// initialize cache with starting values
self.getValues();
return self; return self;
} }
}, {'@type': Object}); }, {'@type': Object});

View File

@ -16,148 +16,187 @@
(function() { (function() {
angular angular
.module('merlin') .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('extractPanels', extractPanels)
.filter('extractRows', extractRows) .filter('extractFields', extractFields)
.filter('extractItems', extractItems); .filter('chunks', chunks);
extractPanels.$inject = ['merlin.utils']; extractPanels.$inject = ['merlin.utils'];
extractRows.$inject = ['merlin.utils'];
extractItems.$inject = ['merlin.utils'];
function extractPanels(utils) { function extractPanels(utils) {
var panelProto = { var panelProto = {
create: function(itemsOrContainer, id) { create: function(enumerator, obj, context) {
if ( angular.isArray(itemsOrContainer) && !itemsOrContainer.length ) { this.$$obj = obj;
return null; this.$$enumerator = enumerator;
} this.removable = false;
if ( angular.isArray(itemsOrContainer) ) { if (this.$$obj) {
this.items = itemsOrContainer; this.id = this.$$obj.uid();
this.id = itemsOrContainer.reduce(function(prevId, item) { this.$$objParent = context.container;
return item.uid() + prevId; this.removable = this.$$objParent.instanceof(Barricade.Arraylike);
}, ''); if (this.$$objParent.instanceof(Barricade.MutableObject)) {
this.title = function() {
if ( arguments.length ) {
obj.setID(arguments[0]);
} else { } else {
this._barricadeContainer = itemsOrContainer; return obj.getID();
this._barricadeId = id; }
var barricadeObj = itemsOrContainer.getByID(id); };
this.id = barricadeObj.uid(); } else if (this.$$objParent.instanceof(Barricade.ImmutableObject)) {
this.items = barricadeObj.getKeys().map(function(key) { this.title = context.indexOrKey;
return utils.enhanceItemWithID(barricadeObj.get(key), key); }
} else {
var id = '';
this.$$enumerator(function(key, item) {
id += item.uid();
}); });
this.removable = true; this.id = id;
} }
return this; return this;
}, },
title: function() { each: function(callback, comparator) {
var newID; this.$$enumerator.call(this.$$obj, callback, comparator);
if ( this._barricadeContainer ) {
if ( arguments.length ) {
newID = arguments[0];
this._barricadeContainer.getByID(this._barricadeId).setID(newID);
this._barricadeId = newID;
} else {
return this._barricadeId;
}
}
}, },
remove: function() { remove: function() {
var container = this._barricadeContainer; var index;
var pos = container.getPosByID(this._barricadeId); if (this.removable) {
container.remove(pos); index = this.$$objParent.toArray().indexOf(this.$$obj);
this.$$objParent.remove(index);
}
} }
}; };
function isPanelsRoot(item) { return _.memoize(function(container, keyExtractor) {
try { var items = [];
// check for 'actions' and 'workflows' containers var _data = {};
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();
var panels = []; var panels = [];
utils.groupByMetaKey(items, 'panelIndex').forEach(function(items) {
var panelsRoot = extractPanelsRoot(items); /* This function recursively applies determinant 'keyExtractor' function
if ( panelsRoot ) { to each container (given that the determinant doesn't return a numeric
panelsRoot.getIDs().forEach(function(id) { value for it), starting from the top-level. Fields for which determinant
panels.push(Object.create(panelProto).create(panelsRoot, id)); returns a numeric value, will be later placed into a panels (see docs for
}); 'extractPanels' filter).
} else { */
panels.push(Object.create(panelProto).create(items)); 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); return utils.condense(panels);
}, function(container) { }, function(container, keyExtractor) {
var hash = ''; var hash = '';
container.getKeys().map(function(key) { function rec(container) {
var item = container.get(key); container.each(function(indexOrKey, item) {
if ( isPanelsRoot(item) ) { var groupingKey = keyExtractor(item, container);
item.getIDs().forEach(function(id) { if (angular.isNumber(groupingKey)) {
hash += item.getByID(id).uid();
});
} else {
hash += item.uid(); hash += item.uid();
} else if (item.instanceof(Barricade.Container)) {
rec(item);
} }
}); });
}
rec(container);
return hash; return hash;
}); });
} }
function extractRows(utils) { function extractFields() {
function getItems(panelOrContainer) { return _.memoize(function(container) {
if ( panelOrContainer.items ) { var fields = {};
return panelOrContainer.items; container.each(function(key, item) {
} else if ( panelOrContainer.getKeys ) { fields[key] = item;
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);
}); });
return fields;
}, function(panel) { }, function(panel) {
var hash = ''; var hash = '';
getItems(panel).forEach(function(item) { panel.each(function(key, item) {
hash += item.uid(); hash += item.uid();
}); });
return hash; return hash;
}); });
} }
function extractItems(utils) { function chunks() {
return _.memoize(function(row) { return _.memoize(function(fields, itemsPerChunk) {
return row.items.sort(function(item1, item2) { var chunks = [];
return utils.getMeta(item1, 'index') - utils.getMeta(item2, 'index'); var keys = Object.keys(fields);
}); var i, j, chunk;
}, function(row) { 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 = ''; var hash = '';
row.items.forEach(function(item) { var key;
hash += item.uid(); for (key in fields) {
}); if (fields.hasOwnProperty(key)) {
hash += fields[key].uid();
}
}
return hash; return hash;
}); });
} }
})(); })();

View File

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

View File

@ -23,16 +23,16 @@
return 'id-' + idCounter; return 'id-' + idCounter;
} }
function groupByMetaKey(sequence, metaKey, insertAtBeginning) { function groupByExtractedKey(sequence, keyExtractor, insertAtBeginning) {
var newSequence = []; var newSequence = [];
var defaultBucket = []; var defaultBucket = [];
var index; var index;
sequence.forEach(function(item) { sequence.forEach(function(item) {
index = getMeta(item, metaKey); index = keyExtractor(item);
if ( angular.isDefined(index) ) { if ( angular.isDefined(index) ) {
if ( !newSequence[index] ) { if ( !newSequence[index] ) {
newSequence[index] = []; newSequence[index] = [];
newSequence[index][metaKey] = index; newSequence[index][keyExtractor()] = index;
} }
newSequence[index].push(item); newSequence[index].push(item);
} else { } else {
@ -51,6 +51,17 @@
return newSequence; 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) { function getMeta(item, key) {
if ( item ) { if ( item ) {
var meta = item._schema['@meta']; var meta = item._schema['@meta'];
@ -103,6 +114,7 @@
getMeta: getMeta, getMeta: getMeta,
getNewId: getNewId, getNewId: getNewId,
groupByMetaKey: groupByMetaKey, groupByMetaKey: groupByMetaKey,
groupByExtractedKey: groupByExtractedKey,
makeTitle: makeTitle, makeTitle: makeTitle,
getNextIDSuffix: getNextIDSuffix, getNextIDSuffix: getNextIDSuffix,
enhanceItemWithID: enhanceItemWithID, 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-default.merlin-panel {
.panel-heading { .panel-heading {
color: inherit; color: inherit;
background-color: inherit; background-color: inherit;
border: none; border: none;
padding-left: 20px;
}
.panel-body {
padding-left: 20px;
} }
textarea { textarea {
resize: vertical; resize: vertical;
@ -64,19 +19,15 @@ editable {
} }
.section { .section {
.form-group { h5 {
padding-left: 15px; font-weight: bold;
}
.section {
margin-left: 15px;
} }
.section-heading {
a { a {
padding-left: 5px;
text-decoration: none; text-decoration: none;
color: black; 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 { .list .add-btn {
margin-top: 2px; margin-bottom: 15px;
&.varlist-1st-row {
margin-top: 26px;
}
}
.right-column .form-group {
padding-left: 0;
} }
.well .panel-body pre { .well .panel-body pre {
@ -124,12 +55,10 @@ editable {
} }
i.fa-times-circle { i.fa-times-circle {
padding-right: 10px;
.section .section & {
font-weight: bold;
margin-top: 10px;
margin-bottom: 0;
font-size: 15px; font-size: 15px;
color: inherit; color: inherit;
.section .section .section-heading & {
margin-top: 7px;
} }
} };

View File

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

View File

@ -1,9 +1,11 @@
<div class="panel panel-default merlin-panel"> <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"> <h4 class="panel-title">
<a ng-click="isCollapsed = !isCollapsed" href=""> <a ng-click="isCollapsed = !isCollapsed" href="">
<i class="fa fa-lg" ng-class="isCollapsed ? 'fa-caret-right' : 'fa-caret-down'"></i></a> <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()"> <a href="" ng-show="panel.removable" ng-click="panel.remove()">
<i class="fa fa-times-circle pull-right"></i></a> <i class="fa fa-times-circle pull-right"></i></a>
</h4> </h4>

View File

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

View File

@ -1,19 +1,34 @@
<collapsible-group content="value" on-add="value.add()"> <div class="row bottom-xs dictionary">
<div class="three-columns" ng-repeat="subvalue in value.getValues() track by subvalue.keyValue()"> <div ng-class="value.inline ? 'col-xs-10' : 'col-xs-12'">
<div class="left-column"> <div ng-repeat="(key, field) in value | extractFields track by field.uid()">
<div class="form-group"> <div ng-if="field.isAtomic()" class="form-group">
<label for="elem-{$ $id $}.{$ subvalue.uid() $}"> <label for="{$ field.uid() $}">
<editable ng-model="subvalue.keyValue" ng-model-options="{getterSetter: true}"></editable> <editable ng-model="field.keyValue" ng-model-options="{getterSetter: true}"></editable>
</label> </label>
<div class="input-group"> <div class="input-group">
<input id="elem-{$ $id $}.{$ subvalue.uid() $}" type="text" class="form-control" <typed-field id="{$ field.uid() $}" value="field" type="{$ field.getType() $}"></typed-field>
ng-model="subvalue.value" ng-model-options="{ getterSetter: true }">
<span class="input-group-btn"> <span class="input-group-btn">
<button class="btn btn-default fa fa-minus-circle" type="button" <button class="btn btn-default" ng-click="value.removeItem(field.keyValue())">
ng-click="value.removeItem(subvalue.keyValue())"></button> <i class="fa fa-minus-circle"></i>
</button>
</span> </span>
</div> </div>
</div> </div>
</div> <div ng-if="!field.isAtomic()">
</div> <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> </collapsible-group>
<typed-field ng-if="field.inline"
value="field" type="{$ field.getType() $}"></typed-field>
</div>
</div>
</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 class="frozendict">
<div ng-repeat="row in value | extractRows track by row.id"> <div ng-repeat="row in value | extractFields | chunks:2 track by $index">
<div ng-class="{'three-columns': row.index !== undefined}"> <div ng-repeat="(key, field) in row track by field.uid()">
<div ng-repeat="item in row | extractItems track by item.uid()" <div ng-if="field.isAtomic()" class="col-xs-6">
ng-class="{'right-column': item.isAtomic() && $odd, 'left-column': item.isAtomic() && $even}"> <labeled label="{$ key $}" for="{$ field.uid() $}">
<div class="form-group"> <typed-field value="field" type="{$ field.getType() $}"></typed-field>
<label for="elem-{$ $id $}.{$ item.uid() $}">{$ item.title() $}</label> </labeled>
<input type="text" class="form-control" id="elem-{$ $id $}.{$ item.uid() $}" ng-model="item.value"
ng-model-options="{getterSetter: true}">
</div>
<div class="clearfix" ng-if="$odd"></div>
</div>
</div> </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> </div>
</collapsible-group> </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>
</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="row bottom-xs list">
<div class="three-columns"> <div ng-class="value.inline ? 'col-xs-10' : 'col-xs-12'">
<div class="left-column"> <div ng-repeat="(index, field) in value | extractFields track by field.uid()">
<div class="form-group" ng-repeat="subItem in value.getValues() track by $index"> <div ng-if="field.isAtomic()" class="form-group">
<div class="input-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"> <span class="input-group-btn">
<button class="btn btn-default" ng-click="value.remove($index)"> <button class="btn btn-default" ng-click="value.remove($index)">
<i class="fa fa-minus-circle"></i> <i class="fa fa-minus-circle"></i>
@ -11,6 +11,13 @@
</span> </span>
</div> </div>
</div> </div>
<div ng-if="!field.isAtomic()">
<typed-field value="field" type="{$ field.getType() $}"></typed-field>
</div>
</div>
</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>
</div> </div>
</collapsible-group>

View File

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

View File

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

View File

@ -1,7 +1,4 @@
<div class="form-group"> <textarea class="form-control" id="{$ value.uid() $}"
<label for="elem-{$ $id $}">{$ value.title() $}</label>
<textarea class="form-control" id="elem-{$ $id $}"
ng-model="value.value" ng-model-options="{ getterSetter: true }" ng-model="value.value" ng-model-options="{ getterSetter: true }"
validatable-with="value"></textarea> validatable-with="value"></textarea>
<div ng-show="error" class="alert alert-danger">{$ error $}</div> <div ng-show="error" class="alert alert-danger">{$ error $}</div>
</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; 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', var title = 'My Panel',
element1, element2; element1, element2;

File diff suppressed because it is too large Load Diff

View File

@ -47,41 +47,18 @@ describe('merlin models:', function() {
return value; 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() { describe('add() method', function() {
it('adds an empty value with given key', function() { it('adds an empty value with given key', function() {
dictObj.add('id3'); dictObj.add('id3');
expect(dictObj.getByID('id3').get()).toBe(''); expect(dictObj.getByID('id3').get()).toBe('');
expect(getCacheIDs()).toEqual(['id1', 'id2', 'id3']);
}); });
it('keyValue() getter/setter can be used for added values', function() { it('keyValue() getter/setter can be used for added values', function() {
var value; var value;
dictObj.add('id3'); dictObj.add('id3');
value = getValueFromCache('id3'); value = dictObj.getByID('id3');
expect(value.keyValue()).toBe('id3'); expect(value.keyValue()).toBe('id3');
@ -112,31 +89,28 @@ describe('merlin models:', function() {
}); });
describe('empty() method', function() { describe('empty() method', function() {
it('removes all entries in model and in cache', function() { it('removes all entries in model', function() {
dictObj.empty(); dictObj.empty();
expect(dictObj.getIDs().length).toBe(0); expect(dictObj.getIDs().length).toBe(0);
expect(dictObj.getValues().length).toBe(0);
}) })
}); });
describe('resetKeys() method', function() { 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']); dictObj.resetKeys(['key1', 'key2']);
expect(dictObj.getIDs()).toEqual(['key1', 'key2']); expect(dictObj.getIDs()).toEqual(['key1', 'key2']);
expect(dictObj.getByID('key1').get()).toBe(''); expect(dictObj.getByID('key1').get()).toBe('');
expect(dictObj.getByID('key2').get()).toBe(''); expect(dictObj.getByID('key2').get()).toBe('');
expect(getCacheIDs()).toEqual(['key1', 'key2']);
}) })
}); });
describe('removeItem() method', function() { 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'); dictObj.removeItem('id1');
expect(dictObj.getByID('id1')).toBeUndefined(); expect(dictObj.getByID('id1')).toBeUndefined();
expect(getCacheIDs()).toEqual(['id2']);
}) })
}); });