Merge "Refactor templates to make them composable"

This commit is contained in:
Jenkins 2015-07-30 21:23:25 +00:00 committed by Gerrit Code Review
commit ec9b892de4
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,25 +91,25 @@
}
});
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
}
})
'yaql': {
'@class': fields.string
},
'action': {
'@class': fields.string
}
});
models.yaqllist = fields.list.extend({}, {
'*': {'@class': models.YAQLField}
});
models.Action = fields.frozendict.extend({
create: function(json, parameters) {
var self = fields.frozendict.create.call(this, json, parameters);
@ -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,14 +102,12 @@
</div>
</div>
<!-- page footer -->
<div class="two-panels">
<div class="full-width">
<div class="pull-right">
<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' $}
</button>
</div>
<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' $}
</button>
</div>
</div>
</div>

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)();
scope.isCollapsed = false;
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 {
return obj.getID();
}
};
} else if (this.$$objParent.instanceof(Barricade.ImmutableObject)) {
this.title = context.indexOrKey;
}
} 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);
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));
});
/* 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 {
panels.push(Object.create(panelProto).create(items));
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 {
hash += item.uid();
}
});
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);
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;
});
}
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,20 +19,16 @@ editable {
}
.section {
.form-group {
padding-left: 15px;
}
.section {
margin-left: 15px;
}
a {
padding-left: 5px;
text-decoration: none;
color: black;
}
h5 {
font-weight: bold;
}
.section-heading {
a {
text-decoration: none;
color: black;
}
}
}
.fa-minus-circle {
@ -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;
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"
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 $}"
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>
<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="{$ 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>

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 }">
<span class="input-group-btn">
<button class="btn btn-default fa fa-minus-circle" type="button"
ng-click="value.removeItem(subvalue.keyValue())"></button>
</span>
<typed-field id="{$ field.uid() $}" value="field" type="{$ field.getType() $}"></typed-field>
<span class="input-group-btn">
<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>
</div>
</collapsible-group>
<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>
<div class="clearfix" ng-if="$odd"></div>
<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 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,16 +1,23 @@
<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 }">
<span class="input-group-btn">
<button class="btn btn-default" ng-click="value.remove($index)">
<i class="fa fa-minus-circle"></i>
</button>
</span>
<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>
</button>
</span>
</div>
</div>
<div ng-if="!field.isAtomic()">
<typed-field value="field" type="{$ field.getType() $}"></typed-field>
</div>
</div>
</div>
</collapsible-group>
<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 $}"
ng-model="value.value" ng-model-options="{ getterSetter: true }"
validatable-with="value">
<div ng-show="error" class="alert alert-danger">{$ error $}</div>
</div>
<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>

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 $}"
ng-model="value.value" ng-model-options="{ getterSetter: true }"
validatable-with="value">
<div ng-show="error" class="alert alert-danger">{$ error $}</div>
</div>
<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>

View File

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

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']);
})
});