summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJenkins <jenkins@review.openstack.org>2015-07-30 21:23:25 +0000
committerGerrit Code Review <review@openstack.org>2015-07-30 21:23:25 +0000
commitec9b892de46180be56e87f3ead91813925f398f4 (patch)
tree23397e9080baccab40f4b01a03439223af372e8b
parent8fd73d4a3a17fadc4cd09b8c004b1648d005658e (diff)
parentf605ff7b8c9c6ee0e7814d57b3ed66e8b03c4332 (diff)
Merge "Refactor templates to make them composable"
-rw-r--r--bower.json3
-rw-r--r--extensions/mistral/static/mistral/js/mistral.init.js2
-rw-r--r--extensions/mistral/static/mistral/js/mistral.workbook.controllers.js14
-rw-r--r--extensions/mistral/static/mistral/js/mistral.workbook.models.js112
-rw-r--r--extensions/mistral/static/mistral/templates/fields/varlist.html91
-rw-r--r--extensions/mistral/static/mistral/templates/fields/yaqlfield.html22
-rw-r--r--extensions/mistral/static/mistral/templates/fields/yaqllist.html30
-rw-r--r--extensions/mistral/templates/mistral/create.html59
-rw-r--r--extensions/mistral/test/js/workbook.model.spec.js7
-rw-r--r--merlin/static/merlin/js/merlin.directives.js37
-rw-r--r--merlin/static/merlin/js/merlin.field.models.js74
-rw-r--r--merlin/static/merlin/js/merlin.filters.js245
-rw-r--r--merlin/static/merlin/js/merlin.init.js2
-rw-r--r--merlin/static/merlin/js/merlin.utils.js18
-rw-r--r--merlin/static/merlin/scss/merlin.scss99
-rw-r--r--merlin/static/merlin/templates/collapsible-group.html12
-rw-r--r--merlin/static/merlin/templates/collapsible-panel.html6
-rw-r--r--merlin/static/merlin/templates/fields/choices.html29
-rw-r--r--merlin/static/merlin/templates/fields/dictionary.html41
-rw-r--r--merlin/static/merlin/templates/fields/frozendict.html33
-rw-r--r--merlin/static/merlin/templates/fields/group.html13
-rw-r--r--merlin/static/merlin/templates/fields/list.html29
-rw-r--r--merlin/static/merlin/templates/fields/number.html12
-rw-r--r--merlin/static/merlin/templates/fields/string.html11
-rw-r--r--merlin/static/merlin/templates/fields/text.html11
-rw-r--r--merlin/static/merlin/templates/labeled.html4
-rw-r--r--merlin/test/js/merlin.directives.spec.js2
-rw-r--r--merlin/test/js/merlin.filters.spec.js954
-rw-r--r--merlin/test/js/merlin.models.spec.js34
29 files changed, 770 insertions, 1236 deletions
diff --git a/bower.json b/bower.json
index f9e9a07..11088b5 100644
--- a/bower.json
+++ b/bower.json
@@ -14,7 +14,8 @@
14 "angular-moment": "0.9.0", 14 "angular-moment": "0.9.0",
15 "angular-cache": "3.2.5", 15 "angular-cache": "3.2.5",
16 "js-yaml": "3.2.7", 16 "js-yaml": "3.2.7",
17 "underscore": "1.8.3" 17 "underscore": "1.8.3",
18 "flexboxgrid": "6.2.0"
18 }, 19 },
19 "devDependencies": { 20 "devDependencies": {
20 "angular-mocks": "1.3.10", 21 "angular-mocks": "1.3.10",
diff --git a/extensions/mistral/static/mistral/js/mistral.init.js b/extensions/mistral/static/mistral/js/mistral.init.js
index 3e06ddf..5e1a015 100644
--- a/extensions/mistral/static/mistral/js/mistral.init.js
+++ b/extensions/mistral/static/mistral/js/mistral.init.js
@@ -12,7 +12,7 @@
12 12
13 function initModule(templates) { 13 function initModule(templates) {
14 templates.prefetch('/static/mistral/templates/fields/', 14 templates.prefetch('/static/mistral/templates/fields/',
15 ['varlist', 'yaqllist']); 15 ['yaqlfield']);
16 } 16 }
17 17
18})(); 18})();
diff --git a/extensions/mistral/static/mistral/js/mistral.workbook.controllers.js b/extensions/mistral/static/mistral/js/mistral.workbook.controllers.js
index f4830dd..7416ae2 100644
--- a/extensions/mistral/static/mistral/js/mistral.workbook.controllers.js
+++ b/extensions/mistral/static/mistral/js/mistral.workbook.controllers.js
@@ -35,6 +35,20 @@
35 }); 35 });
36 }; 36 };
37 37
38 // Please see the explanation of how this determinant function works
39 // in the 'extractPanels' filter documentation
40 vm.keyExtractor = function(item, parent) {
41 if (item.instanceof(models.Action)) {
42 return 500 + parent.toArray().indexOf(item);
43 } else if (item.instanceof(models.Workflow)) {
44 return 1000 + parent.toArray().indexOf(item);
45 } else if (item.instanceof(Barricade.Container)) {
46 return null;
47 } else {
48 return 0;
49 }
50 };
51
38 function getNextIDSuffix(container, regexp) { 52 function getNextIDSuffix(container, regexp) {
39 var max = Math.max.apply(Math, container.getIDs().map(function(id) { 53 var max = Math.max.apply(Math, container.getIDs().map(function(id) {
40 var match = regexp.exec(id); 54 var match = regexp.exec(id);
diff --git a/extensions/mistral/static/mistral/js/mistral.workbook.models.js b/extensions/mistral/static/mistral/js/mistral.workbook.models.js
index cad9f40..5f403eb 100644
--- a/extensions/mistral/static/mistral/js/mistral.workbook.models.js
+++ b/extensions/mistral/static/mistral/js/mistral.workbook.models.js
@@ -18,11 +18,15 @@
18 if ( angular.isUndefined(json) || type === String ) { 18 if ( angular.isUndefined(json) || type === String ) {
19 return fields.string.create(json, parameters); 19 return fields.string.create(json, parameters);
20 } else if ( type === Array ) { 20 } else if ( type === Array ) {
21 return fields.list.extend({}, { 21 return fields.list.extend({
22 inline: true
23 }, {
22 '*': {'@class': fields.string} 24 '*': {'@class': fields.string}
23 }).create(json, parameters); 25 }).create(json, parameters);
24 } else if ( type === Object ) { 26 } else if ( type === Object ) {
25 return fields.dictionary.extend({}, { 27 return fields.dictionary.extend({
28 inline: true
29 }, {
26 '?': {'@class': fields.string} 30 '?': {'@class': fields.string}
27 }).create(json, parameters); 31 }).create(json, parameters);
28 } 32 }
@@ -31,7 +35,6 @@
31 models.varlist = fields.list.extend({ 35 models.varlist = fields.list.extend({
32 create: function(json, parameters) { 36 create: function(json, parameters) {
33 var self = fields.list.create.call(this, json, parameters); 37 var self = fields.list.create.call(this, json, parameters);
34 self.setType('varlist');
35 self.on('childChange', function(child, op) { 38 self.on('childChange', function(child, op) {
36 if ( op == 'empty' ) { 39 if ( op == 'empty' ) {
37 self.each(function(index, item) { 40 self.each(function(index, item) {
@@ -48,6 +51,7 @@
48 '@class': fields.frozendict.extend({ 51 '@class': fields.frozendict.extend({
49 create: function(json, parameters) { 52 create: function(json, parameters) {
50 var self = fields.frozendict.create.call(this, json, parameters); 53 var self = fields.frozendict.create.call(this, json, parameters);
54 self.isAtomic = function() { return false; };
51 self.on('childChange', function(child) { 55 self.on('childChange', function(child) {
52 if ( child.instanceof(Barricade.Enumerated) ) { // type change 56 if ( child.instanceof(Barricade.Enumerated) ) { // type change
53 var value = self.get('value'); 57 var value = self.get('value');
@@ -87,25 +91,25 @@
87 } 91 }
88 }); 92 });
89 93
90 models.yaqllist = fields.list.extend({ 94 models.YAQLField = fields.frozendict.extend({
91 create: function(json, parameters) { 95 create: function(json, parameters) {
92 var self = fields.list.create.call(this, json, parameters); 96 var self = fields.frozendict.create.call(this, json, parameters);
93 self.setType('yaqllist'); 97 self.setType('yaqlfield');
94 return self; 98 return self;
95 } 99 }
96 }, { 100 }, {
97 '*': { 101 'yaql': {
98 '@class': fields.frozendict.extend({}, { 102 '@class': fields.string
99 'yaql': { 103 },
100 '@class': fields.string 104 'action': {
101 }, 105 '@class': fields.string
102 'action': {
103 '@class': fields.string
104 }
105 })
106 } 106 }
107 }); 107 });
108 108
109 models.yaqllist = fields.list.extend({}, {
110 '*': {'@class': models.YAQLField}
111 });
112
109 models.Action = fields.frozendict.extend({ 113 models.Action = fields.frozendict.extend({
110 create: function(json, parameters) { 114 create: function(json, parameters) {
111 var self = fields.frozendict.create.call(this, json, parameters); 115 var self = fields.frozendict.create.call(this, json, parameters);
@@ -135,8 +139,7 @@
135 } 139 }
136 }, { 140 }, {
137 '@meta': { 141 '@meta': {
138 'index': 1, 142 'index': 1
139 'row': 0
140 } 143 }
141 }) 144 })
142 }, 145 },
@@ -144,18 +147,24 @@
144 '@class': fields.dictionary.extend({ 147 '@class': fields.dictionary.extend({
145 create: function(json, parameters) { 148 create: function(json, parameters) {
146 var self = fields.dictionary.create.call(this, json, parameters); 149 var self = fields.dictionary.create.call(this, json, parameters);
150 self.isAdditive = function() { return false; };
147 self.setType('frozendict'); 151 self.setType('frozendict');
148 return self; 152 return self;
153 },
154 // here we override `each' method inherited from fields.dictionary<-MutableObject
155 // because it provides entry index as the first argument of the callback, while
156 // we need to get the key/ID value as first argument (mimicking the `each' method
157 // ImmutableObject)
158 each: function(callback) {
159 var self = this;
160 this.getIDs().forEach(function(id) {
161 callback.call(self, id, self.getByID(id));
162 });
163 return this;
149 } 164 }
150 }, { 165 }, {
151 '@required': false, 166 '@required': false,
152 '?': { 167 '?': {'@class': fields.string},
153 '@class': fields.string.extend({}, {
154 '@meta': {
155 'row': 0
156 }
157 })
158 },
159 '@meta': { 168 '@meta': {
160 'index': 2, 169 'index': 2,
161 'title': 'Base Input' 170 'title': 'Base Input'
@@ -189,9 +198,6 @@
189 }); 198 });
190 return self; 199 return self;
191 }, 200 },
192 remove: function() {
193 this.emit('change', 'taskRemove', this.getID());
194 },
195 _getPrettyJSON: function() { 201 _getPrettyJSON: function() {
196 var json = fields.frozendict._getPrettyJSON.apply(this, arguments); 202 var json = fields.frozendict._getPrettyJSON.apply(this, arguments);
197 delete json.type; 203 delete json.type;
@@ -200,10 +206,7 @@
200 }, { 206 }, {
201 '@meta': { 207 '@meta': {
202 'baseKey': 'task', 208 'baseKey': 'task',
203 'baseName': 'Task ', 209 'baseName': 'Task '
204 'group': true,
205 'additive': false,
206 'removable': true
207 }, 210 },
208 'type': { 211 'type': {
209 '@class': fields.string.extend({}, { 212 '@class': fields.string.extend({}, {
@@ -214,16 +217,14 @@
214 }], 217 }],
215 '@default': 'action', 218 '@default': 'action',
216 '@meta': { 219 '@meta': {
217 'index': 0, 220 'index': 0
218 'row': 0
219 } 221 }
220 }) 222 })
221 }, 223 },
222 'description': { 224 'description': {
223 '@class': fields.text.extend({}, { 225 '@class': fields.text.extend({}, {
224 '@meta': { 226 '@meta': {
225 'index': 2, 227 'index': 2
226 'row': 1
227 } 228 }
228 }) 229 })
229 }, 230 },
@@ -268,7 +269,6 @@
268 '@required': false, 269 '@required': false,
269 '@meta': { 270 '@meta': {
270 'index': 0, 271 'index': 0,
271 'row': 0,
272 'title': 'Wait before' 272 'title': 'Wait before'
273 } 273 }
274 }) 274 })
@@ -278,7 +278,6 @@
278 '@required': false, 278 '@required': false,
279 '@meta': { 279 '@meta': {
280 'index': 1, 280 'index': 1,
281 'row': 0,
282 'title': 'Wait after' 281 'title': 'Wait after'
283 } 282 }
284 }) 283 })
@@ -287,8 +286,7 @@
287 '@class': fields.number.extend({}, { 286 '@class': fields.number.extend({}, {
288 '@required': false, 287 '@required': false,
289 '@meta': { 288 '@meta': {
290 'index': 2, 289 'index': 2
291 'row': 1
292 } 290 }
293 }) 291 })
294 }, 292 },
@@ -297,7 +295,6 @@
297 '@required': false, 295 '@required': false,
298 '@meta': { 296 '@meta': {
299 'index': 3, 297 'index': 3,
300 'row': 2,
301 'title': 'Retry count' 298 'title': 'Retry count'
302 } 299 }
303 }) 300 })
@@ -307,7 +304,6 @@
307 '@required': false, 304 '@required': false,
308 '@meta': { 305 '@meta': {
309 'index': 4, 306 'index': 4,
310 'row': 2,
311 'title': 'Retry delay' 307 'title': 'Retry delay'
312 } 308 }
313 }) 309 })
@@ -317,7 +313,6 @@
317 '@required': false, 313 '@required': false,
318 '@meta': { 314 '@meta': {
319 'index': 5, 315 'index': 5,
320 'row': 3,
321 'title': 'Retry break on' 316 'title': 'Retry break on'
322 } 317 }
323 }) 318 })
@@ -330,7 +325,6 @@
330 'requires': { 325 'requires': {
331 '@class': fields.string.extend({}, { 326 '@class': fields.string.extend({}, {
332 '@meta': { 327 '@meta': {
333 'row': 2,
334 'index': 3 328 'index': 3
335 } 329 }
336 }) 330 })
@@ -386,7 +380,6 @@
386 } 380 }
387 }, { 381 }, {
388 '@meta': { 382 '@meta': {
389 'row': 0,
390 'index': 1 383 'index': 1
391 } 384 }
392 }) 385 })
@@ -407,7 +400,6 @@
407 } 400 }
408 }, { 401 }, {
409 '@meta': { 402 '@meta': {
410 'row': 0,
411 'index': 1 403 'index': 1
412 } 404 }
413 }) 405 })
@@ -446,8 +438,7 @@
446 '@enum': ['reverse', 'direct'], 438 '@enum': ['reverse', 'direct'],
447 '@default': 'direct', 439 '@default': 'direct',
448 '@meta': { 440 '@meta': {
449 'index': 1, 441 'index': 1
450 'row': 0
451 } 442 }
452 }) 443 })
453 }, 444 },
@@ -485,16 +476,13 @@
485 var taskData = child.toJSON(); 476 var taskData = child.toJSON();
486 params.id = taskId; 477 params.id = taskId;
487 self.set(taskPos, TaskFactory(taskData, params)); 478 self.set(taskPos, TaskFactory(taskData, params));
488 } else if ( op === 'taskRemove' ) {
489 self.removeItem(arg);
490 } 479 }
491 }); 480 });
492 return self; 481 return self;
493 } 482 }
494 }, { 483 }, {
495 '@meta': { 484 '@meta': {
496 'index': 5, 485 'index': 5
497 'group': true
498 }, 486 },
499 '?': { 487 '?': {
500 '@class': models.Task, 488 '@class': models.Task,
@@ -511,9 +499,7 @@
511 '@class': fields.frozendict.extend({}, { 499 '@class': fields.frozendict.extend({}, {
512 '@required': false, 500 '@required': false,
513 '@meta': { 501 '@meta': {
514 'index': 4, 502 'index': 4
515 'group': true,
516 'additive': false
517 }, 503 },
518 'on-error': { 504 'on-error': {
519 '@class': models.yaqllist.extend({}, { 505 '@class': models.yaqllist.extend({}, {
@@ -557,8 +543,7 @@
557 models.Actions = fields.dictionary.extend({}, { 543 models.Actions = fields.dictionary.extend({}, {
558 '@required': false, 544 '@required': false,
559 '@meta': { 545 '@meta': {
560 'index': 3, 546 'index': 3
561 'panelIndex': 1
562 }, 547 },
563 '?': { 548 '?': {
564 '@class': models.Action 549 '@class': models.Action
@@ -583,8 +568,7 @@
583 } 568 }
584 }, { 569 }, {
585 '@meta': { 570 '@meta': {
586 'index': 4, 571 'index': 4
587 'panelIndex': 2
588 }, 572 },
589 '?': { 573 '?': {
590 '@class': models.Workflow, 574 '@class': models.Workflow,
@@ -601,9 +585,7 @@
601 '@class': fields.string.extend({}, { 585 '@class': fields.string.extend({}, {
602 '@enum': ['2.0'], 586 '@enum': ['2.0'],
603 '@meta': { 587 '@meta': {
604 'index': 2, 588 'index': 2
605 'panelIndex': 0,
606 'row': 1
607 }, 589 },
608 '@default': '2.0' 590 '@default': '2.0'
609 }) 591 })
@@ -611,9 +593,7 @@
611 'name': { 593 'name': {
612 '@class': fields.string.extend({}, { 594 '@class': fields.string.extend({}, {
613 '@meta': { 595 '@meta': {
614 'index': 0, 596 'index': 0
615 'panelIndex': 0,
616 'row': 0
617 }, 597 },
618 '@constraints': [ 598 '@constraints': [
619 function(value) { 599 function(value) {
@@ -625,9 +605,7 @@
625 'description': { 605 'description': {
626 '@class': fields.text.extend({}, { 606 '@class': fields.text.extend({}, {
627 '@meta': { 607 '@meta': {
628 'index': 1, 608 'index': 1
629 'panelIndex': 0,
630 'row': 0
631 }, 609 },
632 '@required': false 610 '@required': false
633 }) 611 })
diff --git a/extensions/mistral/static/mistral/templates/fields/varlist.html b/extensions/mistral/static/mistral/templates/fields/varlist.html
deleted file mode 100644
index 0c3d3eb..0000000
--- a/extensions/mistral/static/mistral/templates/fields/varlist.html
+++ /dev/null
@@ -1,91 +0,0 @@
1<collapsible-group content="value"
2 on-add="value.add()">
3 <div class="three-columns" ng-repeat="subItem in value.getValues() track by $index"
4 ng-class="subItem.get('type').get()">
5 <div class="left-column">
6 <div class="form-group">
7 <label for="elem-{$ $id $}.$index">Key Type</label>
8 <select id="elem-{$ $id $}.$index" class="form-control"
9 ng-model="subItem.get('type').value" ng-model-options="{getterSetter: true}">
10 <option ng-repeat="value in subItem.get('type').getEnumValues()"
11 value="{$ value $}"
12 ng-selected="subItem.get('type').get() == value">{$ value $}</option>
13 </select>
14 </div>
15 </div>
16 <div ng-switch="subItem.get('type').value()">
17 <!-- draw string input -->
18 <div class="right-column" ng-switch-when="string">
19 <div class="form-group">
20 <label>&nbsp;</label>
21 <div class="input-group">
22 <input type="text" class="form-control"
23 ng-model="subItem.get('value').value" ng-model-options="{getterSetter: true}">
24 <span class="input-group-btn">
25 <button class="btn btn-default" ng-click="value.remove($index)">
26 <i class="fa fa-minus-circle"></i>
27 </button>
28 </span>
29 </div>
30 </div>
31 </div>
32 <!-- END: draw string input -->
33 <!-- draw dictionary inputs -->
34 <div ng-switch-when="dictionary">
35 <div ng-repeat="(key, value) in subItem.get('value').getValues() track by key">
36 <div ng-hide="$first" class="left-column"></div>
37 <div class="right-column">
38 <div class="form-group">
39 <label for="elem-{$ $id $}.{$ key $}">
40 <editable ng-model="value.keyValue" ng-model-options="{getterSetter: true}"></editable>
41 </label>
42 <div class="input-group">
43 <input type="text" id="elem-{$ $id $}.{$ key $}" class="form-control" ng-model="value.value"
44 ng-model-options="{getterSetter: true}">
45 <span class="input-group-btn">
46 <button class="btn btn-default" ng-click="subItem.get('value').remove(key)">
47 <i class="fa fa-minus-circle"></i>
48 </button>
49 </span>
50 </div>
51 </div>
52 </div>
53 <div ng-hide="$last" class="clearfix"></div>
54 <div ng-show="$last" class="add-btn button-column">
55 <button class="btn btn-default btn-sm pull-right" ng-click="subItem.get('value').add()">
56 <i class="fa fa-plus"></i>
57 </button>
58 </div>
59 </div>
60 </div>
61 <!-- END: draw dictionary inputs -->
62 <!-- draw list inputs -->
63 <div ng-switch-when="list">
64 <div ng-repeat="value in subItem.get('value').getValues() track by $index">
65 <div ng-hide="$first" class="left-column"></div>
66 <div class="right-column">
67 <div class="form-group">
68 <label ng-show="$first">&nbsp;</label>
69 <div class="input-group">
70 <input type="text" class="form-control" ng-model="value.value"
71 ng-model-options="{getterSetter: true}">
72 <span class="input-group-btn">
73 <button class="btn btn-default" ng-click="subItem.get('value').remove($index)">
74 <i class="fa fa-minus-circle"></i>
75 </button>
76 </span>
77 </div>
78 </div>
79 </div>
80 <div ng-hide="$last" class="clearfix"></div>
81 <div ng-show="$last" class="add-btn button-column" ng-class="{'varlist-1st-row': !$index}">
82 <button class="btn btn-default btn-sm pull-right" ng-click="subItem.get('value').add()">
83 <i class="fa fa-plus"></i>
84 </button>
85 </div>
86 </div>
87 </div>
88 <!-- END: draw list inputs -->
89 </div>
90 </div>
91</collapsible-group>
diff --git a/extensions/mistral/static/mistral/templates/fields/yaqlfield.html b/extensions/mistral/static/mistral/templates/fields/yaqlfield.html
new file mode 100644
index 0000000..ddf2523
--- /dev/null
+++ b/extensions/mistral/static/mistral/templates/fields/yaqlfield.html
@@ -0,0 +1,22 @@
1<div class="row">
2 <div class="col-xs" ng-show="value.showYaql">
3 <div class="form-group">
4 <textarea class="form-control" ng-model="value.get('yaql').value"
5 ng-model-options="{getterSetter: true}"></textarea>
6 </div>
7 </div>
8 <div class="col-xs-6">
9 <div class="form-group">
10 <div class="input-group">
11 <span class="input-group-btn">
12 <button class="btn btn-default" ng-click="value.showYaql = !value.showYaql;">
13 <i class="fa"
14 ng-class="{'fa-lock': value.get('yaql').value(), 'fa-unlock': !value.get('yaql').value()}"></i>
15 </button>
16 </span>
17 <input type="text" class="form-control" ng-model="value.get('action').value"
18 ng-model-options="{getterSetter: true}">
19 </div>
20 </div>
21 </div>
22</div>
diff --git a/extensions/mistral/static/mistral/templates/fields/yaqllist.html b/extensions/mistral/static/mistral/templates/fields/yaqllist.html
deleted file mode 100644
index c314ea3..0000000
--- a/extensions/mistral/static/mistral/templates/fields/yaqllist.html
+++ /dev/null
@@ -1,30 +0,0 @@
1<collapsible-group content="value" on-add="value.add()">
2 <div class="three-columns"
3 ng-repeat="subItem in value.getValues() track by $index">
4 <div class="left-column" ng-show="subItem.showYaql">
5 <div class="form-group">
6 <textarea class="form-control" ng-model="subItem.get('yaql').value"
7 ng-model-options="{getterSetter: true}"></textarea>
8 </div>
9 </div>
10 <div class="right-column">
11 <div class="form-group">
12 <div class="input-group">
13 <span class="input-group-btn">
14 <button class="btn btn-default" ng-click="subItem.showYaql = !subItem.showYaql;">
15 <i class="fa"
16 ng-class="{'fa-lock': subItem.get('yaql').value(), 'fa-unlock': !subItem.get('yaql').value()}"></i>
17 </button>
18 </span>
19 <input type="text" class="form-control" ng-model="subItem.get('action').value"
20 ng-model-options="{getterSetter: true}">
21 <span class="input-group-btn">
22 <button class="btn btn-default" ng-click="value.remove($index)">
23 <i class="fa fa-minus-circle"></i>
24 </button>
25 </span>
26 </div>
27 </div>
28 </div>
29 </div>
30</collapsible-group>
diff --git a/extensions/mistral/templates/mistral/create.html b/extensions/mistral/templates/mistral/create.html
index 7033859..4ee0011 100644
--- a/extensions/mistral/templates/mistral/create.html
+++ b/extensions/mistral/templates/mistral/create.html
@@ -33,21 +33,22 @@
33 {% compress css %} 33 {% compress css %}
34 <link href='{{ STATIC_URL }}merlin/scss/merlin.scss' type='text/scss' media='screen' rel='stylesheet' /> 34 <link href='{{ STATIC_URL }}merlin/scss/merlin.scss' type='text/scss' media='screen' rel='stylesheet' />
35 {% endcompress %} 35 {% endcompress %}
36<link href='{{ STATIC_URL }}merlin/libs/flexboxgrid/dist/flexboxgrid.css' type='text/css' media='screen' rel='stylesheet' />
36 {% block merlin-css %}{% endblock %} 37 {% block merlin-css %}{% endblock %}
37{% endblock %} 38{% endblock %}
38 39
39{% block main %} 40{% block main %}
40<h3>Create Workbook</h3> 41<h3>Create Workbook</h3>
41 <div id="create-workbook" class="fluid-container" ng-cloak ng-controller="WorkbookController as wb" 42 <div id="create-workbook" ng-cloak ng-controller="WorkbookController as wb"
42 ng-init="wb.init({{ id|default:'undefined' }}, '{{ yaml }}', '{{ commit_url }}', '{{ discard_url }}')"> 43 ng-init="wb.init({{ id|default:'undefined' }}, '{{ yaml }}', '{{ commit_url }}', '{{ discard_url }}')">
43 <div class="well"> 44 <div class="well">
44 <div class="two-panels"> 45 <div class="row">
45 <div class="left-panel"> 46 <div class="col-xs row">
46 <div class="pull-left"> 47 <div class="col-xs start-xs">
47 <h4><strong>{$ wb.workbook.get('name') $}</strong></h4> 48 <h4><strong>{$ wb.workbook.get('name') $}</strong></h4>
48 </div> 49 </div>
49 <div class="pull-right"> 50 <div class="col-xs end-xs">
50 <div class="table-actions clearfix"> 51 <div class="table-actions">
51 <button ng-click="wb.addAction()" class="btn btn-default btn-sm"> 52 <button ng-click="wb.addAction()" class="btn btn-default btn-sm">
52 <span class="fa fa-plus">Add Action</span></button> 53 <span class="fa fa-plus">Add Action</span></button>
53 <button ng-click="wb.addWorkflow()" class="btn btn-default btn-sm"> 54 <button ng-click="wb.addWorkflow()" class="btn btn-default btn-sm">
@@ -55,8 +56,8 @@
55 </div> 56 </div>
56 </div> 57 </div>
57 </div> 58 </div>
58 <div class="right-panel"> 59 <div class="col-xs end-xs">
59 <div class="btn-group btn-toggle pull-right"> 60 <div class="btn-group btn-toggle">
60 <button ng-click="wb.isGraphMode = true" class="btn btn-sm" 61 <button ng-click="wb.isGraphMode = true" class="btn btn-sm"
61 ng-class="wb.isGraphMode ? 'active btn-primary' : 'btn-default'">Graph</button> 62 ng-class="wb.isGraphMode ? 'active btn-primary' : 'btn-default'">Graph</button>
62 <button ng-click="wb.isGraphMode = false" class="btn btn-sm" 63 <button ng-click="wb.isGraphMode = false" class="btn btn-sm"
@@ -65,23 +66,31 @@
65 </div> 66 </div>
66 </div> 67 </div>
67 <!-- Data panel start --> 68 <!-- Data panel start -->
68 <div class="two-panels"> 69 <div class="row">
69 <div class="left-panel"> 70 <div class="col-xs">
70 <panel ng-repeat="panel in wb.workbook | extractPanels track by panel.id" 71 <panel ng-repeat="panel in wb.workbook | extractPanels:wb.keyExtractor track by panel.id"
71 content="panel"> 72 content="panel">
72 <div ng-repeat="row in panel | extractRows track by row.id"> 73 <div ng-repeat="row in panel | extractFields | chunks:2 track by $index">
73 <div ng-class="{'two-columns': row.index !== undefined }"> 74 <div ng-repeat="(label, field) in row track by field.uid()">
74 <div ng-repeat="item in row | extractItems track by item.id" 75 <div ng-if="field.isAtomic()" class="col-xs-6">
75 ng-class="{'right-column': item.isAtomic() && $odd, 'left-column': item.isAtomic() && $even}"> 76 <labeled label="{$ label $}" for="{$ field.uid() $}">
76 <typed-field value="item" type="{$ item.getType() $}"></typed-field> 77 <typed-field value="field" type="{$ field.getType() $}"></typed-field>
77 <div class="clearfix" ng-if="$odd"></div> 78 </labeled>
79 </div>
80 <div ng-if="!field.isAtomic()" class="col-xs-12">
81 <collapsible-group content="field" title="label"
82 additive="{$ field.isAdditive() $}" on-add="field.add()">
83 <div ng-class="field.isPlainStructure() ? 'col-xs-6' : 'col-xs-12'">
84 <typed-field value="field" type="{$ field.getType() $}"></typed-field>
85 </div>
86 </collapsible-group>
78 </div> 87 </div>
79 </div> 88 </div>
80 </div> 89 </div>
81 </panel> 90 </panel>
82 </div> 91 </div>
83 <!-- YAML Panel --> 92 <!-- YAML Panel -->
84 <div class="right-panel"> 93 <div class="col-xs">
85 <div class="panel panel-default"> 94 <div class="panel panel-default">
86 <div class="panel-body" ng-show="!wb.isGraphMode"> 95 <div class="panel-body" ng-show="!wb.isGraphMode">
87 <pre>{$ wb.workbook.toYAML() $}</pre> 96 <pre>{$ wb.workbook.toYAML() $}</pre>
@@ -93,14 +102,12 @@
93 </div> 102 </div>
94 </div> 103 </div>
95 <!-- page footer --> 104 <!-- page footer -->
96 <div class="two-panels"> 105 <div class="row">
97 <div class="full-width"> 106 <div class="col-xs end-xs">
98 <div class="pull-right"> 107 <button ng-click="wb.discardWorkbook()" class="btn btn-default cancel">Cancel</button>
99 <button ng-click="wb.discardWorkbook()" class="btn btn-default cancel">Cancel</button> 108 <button ng-click="wb.commitWorkbook()" class="btn btn-primary">
100 <button ng-click="wb.commitWorkbook()" class="btn btn-primary"> 109 {$ wb.workbookID ? 'Modify' : 'Create' $}
101 {$ wb.workbookID ? 'Modify' : 'Create' $} 110 </button>
102 </button>
103 </div>
104 </div> 111 </div>
105 </div> 112 </div>
106 </div> 113 </div>
diff --git a/extensions/mistral/test/js/workbook.model.spec.js b/extensions/mistral/test/js/workbook.model.spec.js
index 5e80297..7a86f68 100644
--- a/extensions/mistral/test/js/workbook.model.spec.js
+++ b/extensions/mistral/test/js/workbook.model.spec.js
@@ -129,13 +129,6 @@ describe('workbook model logic', function() {
129 expect(json.workflows[workflowID].tasks[newID]).toBeDefined(); 129 expect(json.workflows[workflowID].tasks[newID]).toBeDefined();
130 }); 130 });
131 131
132 it('a task deletion works in conjunction with tasks logic', function() {
133 expect(getTask(taskID)).toBeDefined();
134
135 getTask(taskID).remove();
136 expect(getTask(taskID)).toBeUndefined();
137 });
138
139 }); 132 });
140 133
141 describe("which start with the 'direct' workflow:", function() { 134 describe("which start with the 'direct' workflow:", function() {
diff --git a/merlin/static/merlin/js/merlin.directives.js b/merlin/static/merlin/js/merlin.directives.js
index 510e707..ace9c56 100644
--- a/merlin/static/merlin/js/merlin.directives.js
+++ b/merlin/static/merlin/js/merlin.directives.js
@@ -39,9 +39,21 @@
39 * retrieves a template by its name which is the same as model's type and renders it, 39 * retrieves a template by its name which is the same as model's type and renders it,
40 * recursive <typed-field></..>-s are possible. 40 * recursive <typed-field></..>-s are possible.
41 * */ 41 * */
42 .directive('typedField', typedField); 42 .directive('typedField', typedField)
43 43
44 typedField.$inject = ['$compile', 'merlin.templates']; 44 .directive('labeled', labeled);
45
46 function labeled() {
47 return {
48 restrict: 'E',
49 templateUrl: '/static/merlin/templates/labeled.html',
50 transclude: true,
51 scope: {
52 label: '@',
53 for: '@'
54 }
55 };
56 }
45 57
46 function editable() { 58 function editable() {
47 return { 59 return {
@@ -100,6 +112,7 @@
100 }; 112 };
101 } 113 }
102 114
115 showFocus.$inject = ['$timeout'];
103 function showFocus($timeout) { 116 function showFocus($timeout) {
104 return function(scope, element, attrs) { 117 return function(scope, element, attrs) {
105 // Unused variable created here due to rule 'ng_on_watch': 2 118 // Unused variable created here due to rule 'ng_on_watch': 2
@@ -114,7 +127,7 @@
114 }; 127 };
115 } 128 }
116 129
117 function panel($parse) { 130 function panel() {
118 return { 131 return {
119 restrict: 'E', 132 restrict: 'E',
120 templateUrl: '/static/merlin/templates/collapsible-panel.html', 133 templateUrl: '/static/merlin/templates/collapsible-panel.html',
@@ -122,9 +135,13 @@
122 scope: { 135 scope: {
123 panel: '=content' 136 panel: '=content'
124 }, 137 },
125 link: function(scope, element, attrs) { 138 link: function(scope) {
126 scope.removable = $parse(attrs.removable)(); 139 if (angular.isDefined(scope.panel)) {
127 scope.isCollapsed = false; 140 scope.isCollapsed = false;
141 if (angular.isFunction(scope.panel.title)) {
142 scope.editable = true;
143 }
144 }
128 } 145 }
129 }; 146 };
130 } 147 }
@@ -136,11 +153,15 @@
136 transclude: true, 153 transclude: true,
137 scope: { 154 scope: {
138 group: '=content', 155 group: '=content',
156 title: '=',
139 onAdd: '&', 157 onAdd: '&',
140 onRemove: '&' 158 onRemove: '&'
141 }, 159 },
142 link: function(scope, element, attrs) { 160 link: function(scope, element, attrs) {
143 scope.isCollapsed = false; 161 scope.isCollapsed = false;
162 if (angular.isFunction(scope.title)) {
163 scope.editable = true;
164 }
144 if ( attrs.onAdd && attrs.additive !== 'false' ) { 165 if ( attrs.onAdd && attrs.additive !== 'false' ) {
145 scope.additive = true; 166 scope.additive = true;
146 } 167 }
@@ -151,6 +172,7 @@
151 }; 172 };
152 } 173 }
153 174
175 validatableWith.$inject = ['$parse'];
154 function validatableWith($parse) { 176 function validatableWith($parse) {
155 return { 177 return {
156 restrict: 'A', 178 restrict: 'A',
@@ -186,6 +208,7 @@
186 }; 208 };
187 } 209 }
188 210
211 typedField.$inject = ['$compile', 'merlin.templates'];
189 function typedField($compile, templates) { 212 function typedField($compile, templates) {
190 return { 213 return {
191 restrict: 'E', 214 restrict: 'E',
@@ -195,7 +218,7 @@
195 }, 218 },
196 link: function(scope, element) { 219 link: function(scope, element) {
197 templates.templateReady(scope.type).then(function(template) { 220 templates.templateReady(scope.type).then(function(template) {
198 element.replaceWith($compile(template)(scope)); 221 element.append($compile(template)(scope));
199 }); 222 });
200 } 223 }
201 }; 224 };
diff --git a/merlin/static/merlin/js/merlin.field.models.js b/merlin/static/merlin/js/merlin.field.models.js
index c068e3b..2d1cf08 100644
--- a/merlin/static/merlin/js/merlin.field.models.js
+++ b/merlin/static/merlin/js/merlin.field.models.js
@@ -61,6 +61,24 @@
61 return this; 61 return this;
62 }); 62 });
63 63
64 /* Html renderer helper. The main idea is that fields with simple (or plain)
65 structure (i.e. Atomics = string | number | text | boolean and list or
66 dictionary containing just Atomics) could be rendered in one column, while
67 fields with non plain structure should be rendered in two columns.
68 */
69 var plainStructureMixin = Barricade.Blueprint.create(function() {
70 this.isPlainStructure = function() {
71 if (this.getType() == 'frozendict') {
72 return false;
73 }
74 if (!this.instanceof(Barricade.Arraylike) || !this.length()) {
75 return false;
76 }
77 return !this.get(0).instanceof(Barricade.Container);
78 };
79 return this;
80 });
81
64 var modelMixin = Barricade.Blueprint.create(function(type) { 82 var modelMixin = Barricade.Blueprint.create(function(type) {
65 var isValid = true; 83 var isValid = true;
66 var isValidatable = false; 84 var isValidatable = false;
@@ -90,8 +108,12 @@
90 type = _type; 108 type = _type;
91 }; 109 };
92 110
111 this.isAdditive = function() {
112 return this.instanceof(Barricade.Arraylike);
113 };
114
93 this.isAtomic = function() { 115 this.isAtomic = function() {
94 return ['number', 'string', 'text', 'choices'].indexOf(this.getType()) > -1; 116 return !this.instanceof(Barricade.Container);
95 }; 117 };
96 this.title = function() { 118 this.title = function() {
97 var title = utils.getMeta(this, 'title'); 119 var title = utils.getMeta(this, 'title');
@@ -148,13 +170,8 @@
148 self.add = function() { 170 self.add = function() {
149 self.push(undefined, parameters); 171 self.push(undefined, parameters);
150 }; 172 };
151 self.getValues = function() {
152 return self.toArray();
153 };
154 self._getContents = function() {
155 return self.toArray();
156 };
157 meldGroup.call(self); 173 meldGroup.call(self);
174 plainStructureMixin.call(self);
158 return self; 175 return self;
159 } 176 }
160 }, {'@type': Array}); 177 }, {'@type': Array});
@@ -162,20 +179,10 @@
162 var frozendictModel = Barricade.ImmutableObject.extend({ 179 var frozendictModel = Barricade.ImmutableObject.extend({
163 create: function(json, parameters) { 180 create: function(json, parameters) {
164 var self = Barricade.ImmutableObject.create.call(this, json, parameters); 181 var self = Barricade.ImmutableObject.create.call(this, json, parameters);
165 self.getKeys().forEach(function(key) {
166 utils.enhanceItemWithID(self.get(key), key);
167 });
168 182
169 modelMixin.call(self, 'frozendict'); 183 modelMixin.call(self, 'frozendict');
170 self.getValues = function() {
171 return self._data;
172 };
173 self._getContents = function() {
174 return self.getKeys().map(function(key) {
175 return self.get(key);
176 });
177 };
178 meldGroup.call(self); 184 meldGroup.call(self);
185 plainStructureMixin.call(self);
179 return self; 186 return self;
180 } 187 }
181 }, {'@type': Object}); 188 }, {'@type': Object});
@@ -183,15 +190,14 @@
183 var dictionaryModel = Barricade.MutableObject.extend({ 190 var dictionaryModel = Barricade.MutableObject.extend({
184 create: function(json, parameters) { 191 create: function(json, parameters) {
185 var self = Barricade.MutableObject.create.call(this, json, parameters); 192 var self = Barricade.MutableObject.create.call(this, json, parameters);
186 var _items = [];
187 var _elClass = self._elementClass; 193 var _elClass = self._elementClass;
188 var baseKey = utils.getMeta(_elClass, 'baseKey') || 'key'; 194 var baseKey = utils.getMeta(_elClass, 'baseKey') || 'key';
189 var baseName = utils.getMeta(_elClass, 'baseName') || utils.makeTitle(baseKey); 195 var baseName = utils.getMeta(_elClass, 'baseName') || utils.makeTitle(baseKey);
190 196
191 modelMixin.call(self, 'dictionary'); 197 modelMixin.call(self, 'dictionary');
198 plainStructureMixin.call(self);
192 199
193 function makeCacheWrapper(container, key) { 200 function initKeyAccessor(value) {
194 var value = container.getByID(key);
195 value.keyValue = function () { 201 value.keyValue = function () {
196 if ( arguments.length ) { 202 if ( arguments.length ) {
197 value.setID(arguments[0]); 203 value.setID(arguments[0]);
@@ -199,9 +205,16 @@
199 return value.getID(); 205 return value.getID();
200 } 206 }
201 }; 207 };
202 return value;
203 } 208 }
204 209
210 self.each(function(key, value) {
211 initKeyAccessor(value);
212 }).on('change', function(op, index) {
213 if (op === 'add' || op === 'set') {
214 initKeyAccessor(self.get(index));
215 }
216 });
217
205 self.add = function(newID) { 218 self.add = function(newID) {
206 var regexp = new RegExp('(' + baseKey + ')([0-9]+)'); 219 var regexp = new RegExp('(' + baseKey + ')([0-9]+)');
207 var newValue; 220 var newValue;
@@ -217,21 +230,11 @@
217 newValue = ''; 230 newValue = '';
218 } 231 }
219 self.push(newValue, utils.extend(self._parameters, {id: newID})); 232 self.push(newValue, utils.extend(self._parameters, {id: newID}));
220 _items.push(makeCacheWrapper(self, newID));
221 };
222 self.getValues = function() {
223 if ( !_items.length ) {
224 _items = self.toArray().map(function(value) {
225 return makeCacheWrapper(self, value.getID());
226 });
227 }
228 return _items;
229 }; 233 };
230 self.empty = function() { 234 self.empty = function() {
231 for ( var i = this._data.length; i > 0; i-- ) { 235 for ( var i = this._data.length; i > 0; i-- ) {
232 self.remove(i - 1); 236 self.remove(i - 1);
233 } 237 }
234 _items = [];
235 }; 238 };
236 self.resetKeys = function(keys) { 239 self.resetKeys = function(keys) {
237 self.empty(); 240 self.empty();
@@ -239,17 +242,10 @@
239 self.push(undefined, {id: key}); 242 self.push(undefined, {id: key});
240 }); 243 });
241 }; 244 };
242 self._getContents = function() {
243 return self.toArray();
244 };
245 self.removeItem = function(key) { 245 self.removeItem = function(key) {
246 var pos = self.getPosByID(key);
247 self.remove(self.getPosByID(key)); 246 self.remove(self.getPosByID(key));
248 _items.splice(pos, 1);
249 }; 247 };
250 meldGroup.call(self); 248 meldGroup.call(self);
251 // initialize cache with starting values
252 self.getValues();
253 return self; 249 return self;
254 } 250 }
255 }, {'@type': Object}); 251 }, {'@type': Object});
diff --git a/merlin/static/merlin/js/merlin.filters.js b/merlin/static/merlin/js/merlin.filters.js
index ba5f6fa..6334eaf 100644
--- a/merlin/static/merlin/js/merlin.filters.js
+++ b/merlin/static/merlin/js/merlin.filters.js
@@ -16,148 +16,187 @@
16(function() { 16(function() {
17 angular 17 angular
18 .module('merlin') 18 .module('merlin')
19 /* 'extractPanels' filter requires one argument which should be a function.
20 This function is applied to the top-level elements of the object and the
21 fields for which it returns a numeric value are grouped into the panels. More
22 precisely, each field yielding the same numeric value is put into the same panel.
23 Subclasses of Barricade.Container which don't yield a numeric value (and return
24 null, for example) become the entry points of a recursive application of above
25 algorithm, so eventually each field will be either:
26 * put into a panel (determinant returns numeric value)
27 * recursively scanned for more fields (is a container, no numeric value returned)
28 * or skipped completely (neither of above conditions is met).
29
30 Each returned panel implements at least .each() method (iterating through all key &
31 field pairs of a panel) which could be later consumed by 'extractFields' filter.
32 Filter results are cached, with each field explicitly put into a panel by determinant
33 (i.e. yielding a numeric value) adds its unique id to the caching key. This means that
34 the filter returns a new set of panels if the set of fields explicitly put into panels
35 changes - i.e. a value goes away or comes in into a set or replaced in place with
36 another value (any case is tracked by the unique field id).
37 */
19 .filter('extractPanels', extractPanels) 38 .filter('extractPanels', extractPanels)
20 .filter('extractRows', extractRows) 39 .filter('extractFields', extractFields)
21 .filter('extractItems', extractItems); 40 .filter('chunks', chunks);
22 41
23 extractPanels.$inject = ['merlin.utils']; 42 extractPanels.$inject = ['merlin.utils'];
24 extractRows.$inject = ['merlin.utils'];
25 extractItems.$inject = ['merlin.utils'];
26 43
27 function extractPanels(utils) { 44 function extractPanels(utils) {
28 var panelProto = { 45 var panelProto = {
29 create: function(itemsOrContainer, id) { 46 create: function(enumerator, obj, context) {
30 if ( angular.isArray(itemsOrContainer) && !itemsOrContainer.length ) { 47 this.$$obj = obj;
31 return null; 48 this.$$enumerator = enumerator;
32 } 49 this.removable = false;
33 if ( angular.isArray(itemsOrContainer) ) { 50 if (this.$$obj) {
34 this.items = itemsOrContainer; 51 this.id = this.$$obj.uid();
35 this.id = itemsOrContainer.reduce(function(prevId, item) { 52 this.$$objParent = context.container;
36 return item.uid() + prevId; 53 this.removable = this.$$objParent.instanceof(Barricade.Arraylike);
37 }, ''); 54 if (this.$$objParent.instanceof(Barricade.MutableObject)) {
55 this.title = function() {
56 if ( arguments.length ) {
57 obj.setID(arguments[0]);
58 } else {
59 return obj.getID();
60 }
61 };
62 } else if (this.$$objParent.instanceof(Barricade.ImmutableObject)) {
63 this.title = context.indexOrKey;
64 }
38 } else { 65 } else {
39 this._barricadeContainer = itemsOrContainer; 66 var id = '';
40 this._barricadeId = id; 67 this.$$enumerator(function(key, item) {
41 var barricadeObj = itemsOrContainer.getByID(id); 68 id += item.uid();
42 this.id = barricadeObj.uid();
43 this.items = barricadeObj.getKeys().map(function(key) {
44 return utils.enhanceItemWithID(barricadeObj.get(key), key);
45 }); 69 });
46 this.removable = true; 70 this.id = id;
47 } 71 }
48 return this; 72 return this;
49 }, 73 },
50 title: function() { 74 each: function(callback, comparator) {
51 var newID; 75 this.$$enumerator.call(this.$$obj, callback, comparator);
52 if ( this._barricadeContainer ) {
53 if ( arguments.length ) {
54 newID = arguments[0];
55 this._barricadeContainer.getByID(this._barricadeId).setID(newID);
56 this._barricadeId = newID;
57 } else {
58 return this._barricadeId;
59 }
60 }
61 }, 76 },
62 remove: function() { 77 remove: function() {
63 var container = this._barricadeContainer; 78 var index;
64 var pos = container.getPosByID(this._barricadeId); 79 if (this.removable) {
65 container.remove(pos); 80 index = this.$$objParent.toArray().indexOf(this.$$obj);
81 this.$$objParent.remove(index);
82 }
66 } 83 }
67 }; 84 };
68 85
69 function isPanelsRoot(item) { 86 return _.memoize(function(container, keyExtractor) {
70 try { 87 var items = [];
71 // check for 'actions' and 'workflows' containers 88 var _data = {};
72 return item.instanceof(Barricade.MutableObject); 89 var panels = [];
73 } 90
74 catch(err) { 91 /* This function recursively applies determinant 'keyExtractor' function
75 return false; 92 to each container (given that the determinant doesn't return a numeric
93 value for it), starting from the top-level. Fields for which determinant
94 returns a numeric value, will be later placed into a panels (see docs for
95 'extractPanels' filter).
96 */
97 function rec(container) {
98 container.each(function(indexOrKey, item) {
99 var groupingKey = keyExtractor(item, container);
100 if (angular.isNumber(groupingKey)) {
101 items.push(item);
102 _data[item.uid()] = {
103 groupingKey: groupingKey,
104 container: container,
105 indexOrKey: indexOrKey
106 };
107 } else if (item.instanceof(Barricade.Container)) {
108 rec(item);
109 }
110 });
76 } 111 }
77 } 112 // top-level entry-point of recursive descent
113 rec(container);
78 114
79 function extractPanelsRoot(items) { 115 function extractKey(item) {
80 return isPanelsRoot(items[0]) ? items[0] : null; 116 return angular.isDefined(item) && _data[item.uid()].groupingKey;
81 } 117 }
82 118
83 return _.memoize(function(container) { 119 utils.groupByExtractedKey(items, extractKey).forEach(function(items) {
84 var items = container._getContents(); 120 var parent, enumerator, obj, context;
85 var panels = []; 121 if (items.length > 1 || !items[0].instanceof(Barricade.Container)) {
86 utils.groupByMetaKey(items, 'panelIndex').forEach(function(items) { 122 parent = _data[items[0].uid()].container;
87 var panelsRoot = extractPanelsRoot(items); 123 // the enumerator function mimicking the behavior of built-in .each()
88 if ( panelsRoot ) { 124 // method which aggregate panels do not have
89 panelsRoot.getIDs().forEach(function(id) { 125 enumerator = function(callback) {
90 panels.push(Object.create(panelProto).create(panelsRoot, id)); 126 items.forEach(function(item) {
91 }); 127 if (_data[item.uid()].container === parent) {
128 callback(_data[item.uid()].indexOrKey, item);
129 }
130 });
131 };
92 } else { 132 } else {
93 panels.push(Object.create(panelProto).create(items)); 133 obj = items[0];
134 enumerator = obj.each;
135 context = _data[obj.uid()];
94 } 136 }
137 panels.push(Object.create(panelProto).create(enumerator, obj, context));
95 }); 138 });
96 return utils.condense(panels); 139 return utils.condense(panels);
97 }, function(container) { 140 }, function(container, keyExtractor) {
98 var hash = ''; 141 var hash = '';
99 container.getKeys().map(function(key) { 142 function rec(container) {
100 var item = container.get(key); 143 container.each(function(indexOrKey, item) {
101 if ( isPanelsRoot(item) ) { 144 var groupingKey = keyExtractor(item, container);
102 item.getIDs().forEach(function(id) { 145 if (angular.isNumber(groupingKey)) {
103 hash += item.getByID(id).uid(); 146 hash += item.uid();
104 }); 147 } else if (item.instanceof(Barricade.Container)) {
105 } else { 148 rec(item);
106 hash += item.uid(); 149 }
107 } 150 });
108 }); 151 }
152 rec(container);
109 return hash; 153 return hash;
110 }); 154 });
111 } 155 }
112 156
113 function extractRows(utils) { 157 function extractFields() {
114 function getItems(panelOrContainer) { 158 return _.memoize(function(container) {
115 if ( panelOrContainer.items ) { 159 var fields = {};
116 return panelOrContainer.items; 160 container.each(function(key, item) {
117 } else if ( panelOrContainer.getKeys ) { 161 fields[key] = item;
118 return panelOrContainer.getKeys().map(function(key) {
119 return panelOrContainer.get(key);
120 });
121 } else {
122 return panelOrContainer.getIDs().map(function(id) {
123 return panelOrContainer.getByID(id);
124 });
125 }
126 }
127
128 return _.memoize(function(panel) {
129 var rowProto = {
130 create: function(items) {
131 this.id = items[0].uid();
132 this.index = items.row;
133 this.items = items.slice();
134 return this;
135 }
136 };
137
138 return utils.groupByMetaKey(getItems(panel), 'row').map(function(items) {
139 return Object.create(rowProto).create(items);
140 }); 162 });
163 return fields;
141 }, function(panel) { 164 }, function(panel) {
142 var hash = ''; 165 var hash = '';
143 getItems(panel).forEach(function(item) { 166 panel.each(function(key, item) {
144 hash += item.uid(); 167 hash += item.uid();
145 }); 168 });
146 return hash; 169 return hash;
147 }); 170 });
148 } 171 }
149 172
150 function extractItems(utils) { 173 function chunks() {
151 return _.memoize(function(row) { 174 return _.memoize(function(fields, itemsPerChunk) {
152 return row.items.sort(function(item1, item2) { 175 var chunks = [];
153 return utils.getMeta(item1, 'index') - utils.getMeta(item2, 'index'); 176 var keys = Object.keys(fields);
154 }); 177 var i, j, chunk;
155 }, function(row) { 178 itemsPerChunk = +itemsPerChunk;
179 if (!angular.isNumber(itemsPerChunk) || itemsPerChunk < 1) {
180 return chunks;
181 }
182 for (i = 0; i < keys.length; i++) {
183 chunk = {};
184 for (j = 0; j < itemsPerChunk; j++) {
185 chunk[keys[i]] = fields[keys[i]];
186 }
187 chunks.push(chunk);
188 }
189 return chunks;
190 }, function(fields) {
156 var hash = ''; 191 var hash = '';
157 row.items.forEach(function(item) { 192 var key;
158 hash += item.uid(); 193 for (key in fields) {
159 }); 194 if (fields.hasOwnProperty(key)) {
195 hash += fields[key].uid();
196 }
197 }
160 return hash; 198 return hash;
161 }); 199 });
162 } 200 }
201
163})(); 202})();
diff --git a/merlin/static/merlin/js/merlin.init.js b/merlin/static/merlin/js/merlin.init.js
index 4a51a5d..5a53dcd 100644
--- a/merlin/static/merlin/js/merlin.init.js
+++ b/merlin/static/merlin/js/merlin.init.js
@@ -19,7 +19,7 @@
19 function fieldTemplates() { 19 function fieldTemplates() {
20 return [ 20 return [
21 'dictionary', 'frozendict', 'list', 21 'dictionary', 'frozendict', 'list',
22 'string', 'text', 'group', 'number', 'choices' 22 'string', 'text', 'number', 'choices'
23 ]; 23 ];
24 } 24 }
25 25
diff --git a/merlin/static/merlin/js/merlin.utils.js b/merlin/static/merlin/js/merlin.utils.js
index 8bad961..51089a6 100644
--- a/merlin/static/merlin/js/merlin.utils.js
+++ b/merlin/static/merlin/js/merlin.utils.js
@@ -23,16 +23,16 @@
23 return 'id-' + idCounter; 23 return 'id-' + idCounter;
24 } 24 }
25 25
26 function groupByMetaKey(sequence, metaKey, insertAtBeginning) { 26 function groupByExtractedKey(sequence, keyExtractor, insertAtBeginning) {
27 var newSequence = []; 27 var newSequence = [];
28 var defaultBucket = []; 28 var defaultBucket = [];
29 var index; 29 var index;
30 sequence.forEach(function(item) { 30 sequence.forEach(function(item) {
31 index = getMeta(item, metaKey); 31 index = keyExtractor(item);
32 if ( angular.isDefined(index) ) { 32 if ( angular.isDefined(index) ) {
33 if ( !newSequence[index] ) { 33 if ( !newSequence[index] ) {
34 newSequence[index] = []; 34 newSequence[index] = [];
35 newSequence[index][metaKey] = index; 35 newSequence[index][keyExtractor()] = index;
36 } 36 }
37 newSequence[index].push(item); 37 newSequence[index].push(item);
38 } else { 38 } else {
@@ -51,6 +51,17 @@
51 return newSequence; 51 return newSequence;
52 } 52 }
53 53
54 function groupByMetaKey(sequence, metaKey, insertAtBeginning) {
55 function keyExtractor(item) {
56 if (angular.isDefined(item)) {
57 return getMeta(item, metaKey);
58 } else {
59 return metaKey;
60 }
61 }
62 return groupByExtractedKey(sequence, keyExtractor, insertAtBeginning);
63 }
64
54 function getMeta(item, key) { 65 function getMeta(item, key) {
55 if ( item ) { 66 if ( item ) {
56 var meta = item._schema['@meta']; 67 var meta = item._schema['@meta'];
@@ -103,6 +114,7 @@
103 getMeta: getMeta, 114 getMeta: getMeta,
104 getNewId: getNewId, 115 getNewId: getNewId,
105 groupByMetaKey: groupByMetaKey, 116 groupByMetaKey: groupByMetaKey,
117 groupByExtractedKey: groupByExtractedKey,
106 makeTitle: makeTitle, 118 makeTitle: makeTitle,
107 getNextIDSuffix: getNextIDSuffix, 119 getNextIDSuffix: getNextIDSuffix,
108 enhanceItemWithID: enhanceItemWithID, 120 enhanceItemWithID: enhanceItemWithID,
diff --git a/merlin/static/merlin/scss/merlin.scss b/merlin/static/merlin/scss/merlin.scss
index b33a08c..fa84b67 100644
--- a/merlin/static/merlin/scss/merlin.scss
+++ b/merlin/static/merlin/scss/merlin.scss
@@ -1,53 +1,8 @@
1@import "/bootstrap/scss/bootstrap";
2
3.two-panels {
4 @include make-row();
5 .left-panel {
6 @include make-xs-column(6);
7 }
8 .right-panel {
9 @include make-xs-column(6);
10 }
11 .full-width {
12 @include make-xs-column(12);
13 }
14}
15
16.two-columns {
17 @include make-row();
18 .left-column {
19 @include make-xs-column(6);
20 }
21 .right-column {
22 @include make-xs-column(6);
23 }
24}
25
26.three-columns {
27 @include make-row();
28 .left-column {
29 @include make-xs-column(5);
30 }
31 .right-column {
32 @include make-xs-column(5);
33 }
34 .both-columns {
35 @include make-xs-column(10);
36 }
37 .button-column {
38 @include make-xs-column(2);
39 }
40}
41
42.panel-default.merlin-panel { 1.panel-default.merlin-panel {
43 .panel-heading { 2 .panel-heading {
44 color: inherit; 3 color: inherit;
45 background-color: inherit; 4 background-color: inherit;
46 border: none; 5 border: none;
47 padding-left: 20px;
48 }
49 .panel-body {
50 padding-left: 20px;
51 } 6 }
52 textarea { 7 textarea {
53 resize: vertical; 8 resize: vertical;
@@ -64,20 +19,16 @@ editable {
64} 19}
65 20
66.section { 21.section {
67 .form-group {
68 padding-left: 15px;
69 }
70 .section {
71 margin-left: 15px;
72 }
73 a {
74 padding-left: 5px;
75 text-decoration: none;
76 color: black;
77 }
78 h5 { 22 h5 {
79 font-weight: bold; 23 font-weight: bold;
80 } 24 }
25
26 .section-heading {
27 a {
28 text-decoration: none;
29 color: black;
30 }
31 }
81} 32}
82 33
83.fa-minus-circle { 34.fa-minus-circle {
@@ -93,28 +44,8 @@ editable {
93 } 44 }
94} 45}
95 46
96.popover-content > button {
97 margin: 5px;
98 float: right;
99}
100
101.popover.right {
102 width: 200px;
103}
104
105.dictionary .add-btn {
106 margin-top: 26px;
107}
108
109.list .add-btn { 47.list .add-btn {
110 margin-top: 2px; 48 margin-bottom: 15px;
111 &.varlist-1st-row {
112 margin-top: 26px;
113 }
114}
115
116.right-column .form-group {
117 padding-left: 0;
118} 49}
119 50
120.well .panel-body pre { 51.well .panel-body pre {
@@ -124,12 +55,10 @@ editable {
124} 55}
125 56
126i.fa-times-circle { 57i.fa-times-circle {
127 padding-right: 10px; 58 font-size: 15px;
128 .section .section & { 59 color: inherit;
129 font-weight: bold; 60
130 margin-top: 10px; 61 .section .section .section-heading & {
131 margin-bottom: 0; 62 margin-top: 7px;
132 font-size: 15px;
133 color: inherit;
134 } 63 }
135} 64};
diff --git a/merlin/static/merlin/templates/collapsible-group.html b/merlin/static/merlin/templates/collapsible-group.html
index a67b04a..b549d9b 100644
--- a/merlin/static/merlin/templates/collapsible-group.html
+++ b/merlin/static/merlin/templates/collapsible-group.html
@@ -1,18 +1,18 @@
1<div class="section"> 1<div class="section">
2 <div class="section-heading three-columns"> 2 <div class="section-heading row">
3 <div class="both-columns"> 3 <div class="col-xs-10">
4 <h5><a ng-click="isCollapsed = !isCollapsed" class="collapse-entries" href=""> 4 <h5><a ng-click="isCollapsed = !isCollapsed" class="collapse-entries" href="">
5 <i class="fa" ng-class="isCollapsed ? 'fa-plus-square-o' : 'fa-minus-square-o'"></i></a> 5 <i class="fa" ng-class="isCollapsed ? 'fa-plus-square-o' : 'fa-minus-square-o'"></i></a>
6 <editable ng-if="removable" ng-model="group.title" 6 <editable ng-if="editable" ng-model="title"
7 ng-model-options="{getterSetter: true}"></editable> 7 ng-model-options="{getterSetter: true}"></editable>
8 <span ng-if="!removable">{$ group.title() $}</span> 8 <span ng-if="!editable">{$ ::title $}</span>
9 </h5> 9 </h5>
10 </div> 10 </div>
11 <div ng-if="additive" class="add-btn button-column add-entry"> 11 <div ng-if="additive" class="add-btn col-xs add-entry">
12 <button class="btn btn-default btn-sm pull-right" ng-click="onAdd()"> 12 <button class="btn btn-default btn-sm pull-right" ng-click="onAdd()">
13 <i class="fa fa-plus"></i></button> 13 <i class="fa fa-plus"></i></button>
14 </div> 14 </div>
15 <div ng-if="removable" class="add-btn button-column remove-entry"> 15 <div ng-if="removable" class="add-btn col-xs remove-entry">
16 <a href="" ng-click="onRemove()"> 16 <a href="" ng-click="onRemove()">
17 <i class="fa fa-times-circle pull-right"></i></a> 17 <i class="fa fa-times-circle pull-right"></i></a>
18 </div> 18 </div>
diff --git a/merlin/static/merlin/templates/collapsible-panel.html b/merlin/static/merlin/templates/collapsible-panel.html
index df21a5a..e321d6f 100644
--- a/merlin/static/merlin/templates/collapsible-panel.html
+++ b/merlin/static/merlin/templates/collapsible-panel.html
@@ -1,9 +1,11 @@
1<div class="panel panel-default merlin-panel"> 1<div class="panel panel-default merlin-panel">
2 <div class="panel-heading" ng-show="panel.title()"> 2 <div class="panel-heading" ng-show="panel.title">
3 <h4 class="panel-title"> 3 <h4 class="panel-title">
4 <a ng-click="isCollapsed = !isCollapsed" href=""> 4 <a ng-click="isCollapsed = !isCollapsed" href="">
5 <i class="fa fa-lg" ng-class="isCollapsed ? 'fa-caret-right' : 'fa-caret-down'"></i></a> 5 <i class="fa fa-lg" ng-class="isCollapsed ? 'fa-caret-right' : 'fa-caret-down'"></i></a>
6 <editable ng-model="panel.title" ng-model-options="{getterSetter: true}"></editable> 6 <editable ng-if="editable" ng-model="panel.title"
7 ng-model-options="{getterSetter: true}"></editable>
8 <span ng-if="!editable">{$ ::panel.title $}</span>
7 <a href="" ng-show="panel.removable" ng-click="panel.remove()"> 9 <a href="" ng-show="panel.removable" ng-click="panel.remove()">
8 <i class="fa fa-times-circle pull-right"></i></a> 10 <i class="fa fa-times-circle pull-right"></i></a>
9 </h4> 11 </h4>
diff --git a/merlin/static/merlin/templates/fields/choices.html b/merlin/static/merlin/templates/fields/choices.html
index 6f1b25f..6f3f9f8 100644
--- a/merlin/static/merlin/templates/fields/choices.html
+++ b/merlin/static/merlin/templates/fields/choices.html
@@ -1,16 +1,13 @@
1<div class="form-group"> 1<select ng-if="value.isDropDown()"
2 <label for="elem-{$ $id $}">{$ value.title() $}</label> 2 id="{$ value.uid() $}" class="form-control"
3 <select ng-if="value.isDropDown()" 3 ng-model="value.value" ng-model-options="{getterSetter: true}">
4 id="elem-{$ $id $}" class="form-control" 4 <option ng-repeat="option in value.getValues()"
5 ng-model="value.value" ng-model-options="{getterSetter: true}"> 5 value="{$ option $}"
6 <option ng-repeat="option in value.getValues()" 6 ng-selected="value.get() == option">{$ value.getLabel(option) $}</option>
7 value="{$ option $}" 7</select>
8 ng-selected="value.get() == option">{$ value.getLabel(option) $}</option> 8<input ng-if="!value.isDropDown()"
9 </select> 9 type="text" class="form-control" id="{$ value.uid() $}"
10 <input ng-if="!value.isDropDown()" 10 ng-model="value.value" ng-model-options="{ getterSetter: true }"
11 type="text" class="form-control" id="elem-{$ $id $}" 11 validatable-with="value" typeahead-editable="true"
12 ng-model="value.value" ng-model-options="{ getterSetter: true }" 12 typeahead="option for option in value.getValues() | filter:$viewValue">
13 validatable-with="value" typeahead-editable="true" 13<div ng-show="error" class="alert alert-danger">{$ error $}</div>
14 typeahead="option for option in value.getValues() | filter:$viewValue">
15 <div ng-show="error" class="alert alert-danger">{$ error $}</div>
16</div>
diff --git a/merlin/static/merlin/templates/fields/dictionary.html b/merlin/static/merlin/templates/fields/dictionary.html
index 6d50bb0..33017ac 100644
--- a/merlin/static/merlin/templates/fields/dictionary.html
+++ b/merlin/static/merlin/templates/fields/dictionary.html
@@ -1,19 +1,34 @@
1<collapsible-group content="value" on-add="value.add()"> 1<div class="row bottom-xs dictionary">
2 <div class="three-columns" ng-repeat="subvalue in value.getValues() track by subvalue.keyValue()"> 2 <div ng-class="value.inline ? 'col-xs-10' : 'col-xs-12'">
3 <div class="left-column"> 3 <div ng-repeat="(key, field) in value | extractFields track by field.uid()">
4 <div class="form-group"> 4 <div ng-if="field.isAtomic()" class="form-group">
5 <label for="elem-{$ $id $}.{$ subvalue.uid() $}"> 5 <label for="{$ field.uid() $}">
6 <editable ng-model="subvalue.keyValue" ng-model-options="{getterSetter: true}"></editable> 6 <editable ng-model="field.keyValue" ng-model-options="{getterSetter: true}"></editable>
7 </label> 7 </label>
8 <div class="input-group"> 8 <div class="input-group">
9 <input id="elem-{$ $id $}.{$ subvalue.uid() $}" type="text" class="form-control" 9 <typed-field id="{$ field.uid() $}" value="field" type="{$ field.getType() $}"></typed-field>
10 ng-model="subvalue.value" ng-model-options="{ getterSetter: true }"> 10 <span class="input-group-btn">
11 <span class="input-group-btn"> 11 <button class="btn btn-default" ng-click="value.removeItem(field.keyValue())">
12 <button class="btn btn-default fa fa-minus-circle" type="button" 12 <i class="fa fa-minus-circle"></i>
13 ng-click="value.removeItem(subvalue.keyValue())"></button> 13 </button>
14 </span> 14 </span>
15 </div> 15 </div>
16 </div> 16 </div>
17 <div ng-if="!field.isAtomic()">
18 <collapsible-group ng-if="!field.inline" content="field"
19 class="col-xs-12"
20 title="field.keyValue"
21 on-remove="value.removeItem(field.keyValue())"
22 additive="{$ field.isAdditive() $}" on-add="field.add()">
23 <typed-field value="field" type="{$ field.getType() $}"></typed-field>
24 </collapsible-group>
25 <typed-field ng-if="field.inline"
26 value="field" type="{$ field.getType() $}"></typed-field>
27 </div>
17 </div> 28 </div>
18 </div> 29 </div>
19</collapsible-group> 30 <div ng-if="value.inline" class="col-xs add-entry" style="margin-bottom: 15px">
31 <button class="btn btn-default btn-sm pull-right" ng-click="value.add()">
32 <i class="fa fa-plus"></i></button>
33 </div>
34</div>
diff --git a/merlin/static/merlin/templates/fields/frozendict.html b/merlin/static/merlin/templates/fields/frozendict.html
index a4553c9..97b4c0f 100644
--- a/merlin/static/merlin/templates/fields/frozendict.html
+++ b/merlin/static/merlin/templates/fields/frozendict.html
@@ -1,15 +1,24 @@
1<collapsible-group content="value"> 1<div class="frozendict">
2 <div ng-repeat="row in value | extractRows track by row.id"> 2 <div ng-repeat="row in value | extractFields | chunks:2 track by $index">
3 <div ng-class="{'three-columns': row.index !== undefined}"> 3 <div ng-repeat="(key, field) in row track by field.uid()">
4 <div ng-repeat="item in row | extractItems track by item.uid()" 4 <div ng-if="field.isAtomic()" class="col-xs-6">
5 ng-class="{'right-column': item.isAtomic() && $odd, 'left-column': item.isAtomic() && $even}"> 5 <labeled label="{$ key $}" for="{$ field.uid() $}">
6 <div class="form-group"> 6 <typed-field value="field" type="{$ field.getType() $}"></typed-field>
7 <label for="elem-{$ $id $}.{$ item.uid() $}">{$ item.title() $}</label> 7 </labeled>
8 <input type="text" class="form-control" id="elem-{$ $id $}.{$ item.uid() $}" ng-model="item.value" 8 </div>
9 ng-model-options="{getterSetter: true}"> 9 <div ng-if="!field.isAtomic()">
10 </div> 10 <collapsible-group ng-if="!field.inline" class="col-xs-12"
11 <div class="clearfix" ng-if="$odd"></div> 11 content="field" title="key"
12 additive="{$ field.isAdditive() $}" on-add="field.add()">
13 <div ng-class="field.isPlainStructure() ? 'col-xs-6' : 'col-xs-12'">
14 <typed-field value="field" type="{$ field.getType() $}"></typed-field>
15 </div>
16 </collapsible-group>
17 <labeled ng-if="field.inline" class="col-xs-6"
18 label="{$ key $}" for="{$ field.uid() $}">
19 <typed-field value="field" type="{$ field.getType() $}"></typed-field>
20 </labeled>
12 </div> 21 </div>
13 </div> 22 </div>
14 </div> 23 </div>
15</collapsible-group> 24</div>
diff --git a/merlin/static/merlin/templates/fields/group.html b/merlin/static/merlin/templates/fields/group.html
deleted file mode 100644
index 60fe41c..0000000
--- a/merlin/static/merlin/templates/fields/group.html
+++ /dev/null
@@ -1,13 +0,0 @@
1<collapsible-group content="value" additive="{$ value.isAdditive() $}"
2 on-add="value.add()"
3 removable="{$ value.isRemovable() $}" on-remove="value.remove()">
4 <div ng-repeat="row in value | extractRows track by row.id">
5 <div ng-class="{'three-columns': row.index !== undefined }">
6 <div ng-repeat="item in row | extractItems track by item.id"
7 ng-class="{'right-column': item.isAtomic() && $odd, 'left-column': item.isAtomic() && $even}">
8 <typed-field value="item" type="{$ item.getType() $}"></typed-field>
9 <div class="clearfix" ng-if="$odd"></div>
10 </div>
11 </div>
12 </div>
13</collapsible-group> \ No newline at end of file
diff --git a/merlin/static/merlin/templates/fields/list.html b/merlin/static/merlin/templates/fields/list.html
index f7134b1..379c264 100644
--- a/merlin/static/merlin/templates/fields/list.html
+++ b/merlin/static/merlin/templates/fields/list.html
@@ -1,16 +1,23 @@
1<collapsible-group content="value" on-add="value.add()"> 1<div class="row bottom-xs list">
2 <div class="three-columns"> 2 <div ng-class="value.inline ? 'col-xs-10' : 'col-xs-12'">
3 <div class="left-column"> 3 <div ng-repeat="(index, field) in value | extractFields track by field.uid()">
4 <div class="form-group" ng-repeat="subItem in value.getValues() track by $index"> 4 <div ng-if="field.isAtomic()" class="form-group">
5 <div class="input-group"> 5 <div class="input-group">
6 <input type="text" class="form-control" ng-model="subItem.value" ng-model-options="{ getterSetter: true }"> 6 <typed-field value="field" type="{$ field.getType() $}"></typed-field>
7 <span class="input-group-btn"> 7 <span class="input-group-btn">
8 <button class="btn btn-default" ng-click="value.remove($index)"> 8 <button class="btn btn-default" ng-click="value.remove($index)">
9 <i class="fa fa-minus-circle"></i> 9 <i class="fa fa-minus-circle"></i>
10 </button> 10 </button>
11 </span> 11 </span>
12 </div> 12 </div>
13 </div> 13 </div>
14 <div ng-if="!field.isAtomic()">
15 <typed-field value="field" type="{$ field.getType() $}"></typed-field>
16 </div>
14 </div> 17 </div>
15 </div> 18 </div>
16</collapsible-group> 19 <div ng-if="value.inline" class="col-xs add-btn">
20 <button class="btn btn-default btn-sm pull-right" ng-click="value.add()">
21 <i class="fa fa-plus"></i></button>
22 </div>
23</div>
diff --git a/merlin/static/merlin/templates/fields/number.html b/merlin/static/merlin/templates/fields/number.html
index b0b10e1..3f06840 100644
--- a/merlin/static/merlin/templates/fields/number.html
+++ b/merlin/static/merlin/templates/fields/number.html
@@ -1,8 +1,4 @@
1<div class="form-group"> 1<input type="number" class="form-control" id="{$ value.uid() $}"
2 <pre>{$ value $}, {$ value.title() $}</pre> 2 ng-model="value.value" ng-model-options="{ getterSetter: true }"
3 <label for="elem-{$ $id $}">{$ value.title() $}</label> 3 validatable-with="value">
4 <input type="number" class="form-control" id="elem-{$ $id $}" 4<div ng-show="error" class="alert alert-danger">{$ error $}</div>
5 ng-model="value.value" ng-model-options="{ getterSetter: true }"
6 validatable-with="value">
7 <div ng-show="error" class="alert alert-danger">{$ error $}</div>
8</div>
diff --git a/merlin/static/merlin/templates/fields/string.html b/merlin/static/merlin/templates/fields/string.html
index a6a1978..619bdc8 100644
--- a/merlin/static/merlin/templates/fields/string.html
+++ b/merlin/static/merlin/templates/fields/string.html
@@ -1,7 +1,4 @@
1<div class="form-group"> 1<input type="text" class="form-control" id="{$ value.uid() $}"
2 <label for="elem-{$ $id $}">{$ value.title() $}</label> 2 ng-model="value.value" ng-model-options="{ getterSetter: true }"
3 <input type="text" class="form-control" id="elem-{$ $id $}" 3 validatable-with="value">
4 ng-model="value.value" ng-model-options="{ getterSetter: true }" 4<div ng-show="error" class="alert alert-danger">{$ error $}</div>
5 validatable-with="value">
6 <div ng-show="error" class="alert alert-danger">{$ error $}</div>
7</div>
diff --git a/merlin/static/merlin/templates/fields/text.html b/merlin/static/merlin/templates/fields/text.html
index f873ba7..41960e2 100644
--- a/merlin/static/merlin/templates/fields/text.html
+++ b/merlin/static/merlin/templates/fields/text.html
@@ -1,7 +1,4 @@
1<div class="form-group"> 1<textarea class="form-control" id="{$ value.uid() $}"
2 <label for="elem-{$ $id $}">{$ value.title() $}</label> 2 ng-model="value.value" ng-model-options="{ getterSetter: true }"
3 <textarea class="form-control" id="elem-{$ $id $}" 3 validatable-with="value"></textarea>
4 ng-model="value.value" ng-model-options="{ getterSetter: true }" 4<div ng-show="error" class="alert alert-danger">{$ error $}</div>
5 validatable-with="value"></textarea>
6 <div ng-show="error" class="alert alert-danger">{$ error $}</div>
7</div>
diff --git a/merlin/static/merlin/templates/labeled.html b/merlin/static/merlin/templates/labeled.html
new file mode 100644
index 0000000..f011b06
--- /dev/null
+++ b/merlin/static/merlin/templates/labeled.html
@@ -0,0 +1,4 @@
1<div class="form-group">
2 <label for="{$ for $}">{$ label $}</label>
3 <div ng-transclude></div>
4</div>
diff --git a/merlin/test/js/merlin.directives.spec.js b/merlin/test/js/merlin.directives.spec.js
index 7a3df8e..1b7ede8 100644
--- a/merlin/test/js/merlin.directives.spec.js
+++ b/merlin/test/js/merlin.directives.spec.js
@@ -66,7 +66,7 @@ describe('merlin directives', function() {
66 return element; 66 return element;
67 } 67 }
68 68
69 it('shows panel heading when and only when its title() is not false', function() { 69 it('shows panel heading when and only when its title is defined', function() {
70 var title = 'My Panel', 70 var title = 'My Panel',
71 element1, element2; 71 element1, element2;
72 72
diff --git a/merlin/test/js/merlin.filters.spec.js b/merlin/test/js/merlin.filters.spec.js
index 91102f9..3125956 100644
--- a/merlin/test/js/merlin.filters.spec.js
+++ b/merlin/test/js/merlin.filters.spec.js
@@ -28,778 +28,434 @@ describe('merlin filters', function() {
28 }); 28 });
29 29
30 describe('extractPanels() behavior:', function() { 30 describe('extractPanels() behavior:', function() {
31 var extractPanels, simpleMerlinObjClass, simpleMerlinObjClassWithMeta; 31 var extractPanels, simpleContainerCls, complexContainerCls;
32 32
33 beforeEach(function() { 33 beforeEach(function() {
34 extractPanels = $filter('extractPanels'); 34 extractPanels = $filter('extractPanels');
35 35
36 simpleMerlinObjClass = fields.frozendict.extend({}, { 36 simpleContainerCls = fields.frozendict.extend({}, {
37 'key1': { 37 'key1': {'@class': fields.string},
38 '@class': fields.string 38 'key2': {'@class': fields.string}
39 },
40 'key2': {
41 '@class': fields.string
42 }
43 }); 39 });
44 40
45 simpleMerlinObjClassWithMeta = fields.frozendict.extend({}, { 41 complexContainerCls = fields.frozendict.extend({}, {
46 'key1': { 42 'numberKey': {'@class': fields.number},
47 '@class': fields.number.extend({}, { 43 'stringKey': {'@class': fields.string},
48 '@meta': { 44 'containerKey': {
49 'panelIndex': 0 45 '@class': fields.dictionary.extend({}, {
50 } 46 '?': {'@class': simpleContainerCls}
51 })
52 },
53 'key2': {
54 '@class': fields.string.extend({}, {
55 '@meta': {
56 'panelIndex': 0
57 }
58 })
59 },
60 'key3': {
61 '@class': fields.string.extend({}, {
62 '@meta': {
63 'panelIndex': 1
64 }
65 }) 47 })
66 } 48 }
67 }); 49 });
68 50
69 }); 51 });
70 52
71 it('works properly only with objects created from Merlin classes', function() { 53 describe('filter input expectations', function() {
72 var simpleBarricadeObjClass = Barricade.create({ 54 it('2 arguments are required: Barricade.Container subclass and a keyExtractor function', function() {
73 '@type': Object, 55 var atomicField = fields.string.create();
74 'key1': { 56 var nonAtomicField = simpleContainerCls.create();
75 '@type': Number 57 function extractor() {}
76 }, 58 function wrongCall1() {
77 'key2': { 59 return extractPanels(atomicField, extractor);
78 '@type': String 60 }
61 function wrongCall2() {
62 return extractPanels(nonAtomicField);
63 }
64 function properCall() {
65 return extractPanels(nonAtomicField, extractor);
79 } 66 }
80 }),
81 simpleBarricadeObj = simpleBarricadeObjClass.create(),
82 simpleMerlinObj = simpleMerlinObjClass.create();
83 67
84 expect(function() { 68 expect(wrongCall1).toThrow();
85 return extractPanels(simpleBarricadeObj); 69 expect(wrongCall2).toThrow();
86 }).toThrow(); 70 expect(properCall).not.toThrow();
71 });
87 72
88 expect(function() {
89 return extractPanels(simpleMerlinObj);
90 }).not.toThrow();
91 }); 73 });
92 74
93 describe('the filter relies upon `@meta` object with `panelIndex` key', function() { 75 describe('filter output minimal guarantees', function() {
94 it('and all fields without it are merged into a single panel', function() { 76 var panels, container;
95 var simpleObj = simpleMerlinObjClass.create(), 77 function extractor(field) {
96 panels = extractPanels(simpleObj); 78 return field.instanceof(fields.string) ? 0 : null;
79 }
97 80
98 expect(panels.length).toBe(1); 81 beforeEach(function() {
82 container = simpleContainerCls.create({'key1': 'first', 'key2': 'second'});
83 panels = extractPanels(container, extractor);
99 }); 84 });
100 85
101 it('each entry with the same panelIndex is placed in the same panel', function() { 86 it('a panel in an output provides .each() method', function() {
102 var simpleObj = simpleMerlinObjClassWithMeta.create(), 87 expect(panels[0].each).toBeDefined();
103 panels = extractPanels(simpleObj);
104
105 expect(panels.length).toBe(2);
106 expect(panels[0].items.length).toBe(2);
107 expect(panels[1].items.length).toBe(1);
108 }); 88 });
109 89
110 it('the filter is applied only to the top-level entries of the passed object', function() { 90 it('.each() method could be used for panel contents enumeration', function() {
111 var merlinObjWithNestedPanelIndices = fields.frozendict.extend({}, { 91 var fields = {};
112 'key1': { 92 panels[0].each(function(key, field) {
113 '@class': fields.number.extend({}, { 93 fields[key] = field;
114 '@meta': { 94 });
115 'panelIndex': 0
116 }
117 })
118 },
119 'key2': {
120 '@class': fields.frozendict.extend({}, {
121 'key3': {
122 '@class': fields.string.extend({}, {
123 '@meta': {
124 'panelIndex': 1
125 }
126 })
127 },
128 '@meta': {
129 'panelIndex': 0
130 }
131 })
132 }
133 }).create(),
134 panels = extractPanels(merlinObjWithNestedPanelIndices);
135 95
136 expect(panels.length).toBe(1); 96 expect(fields.key1.get()).toEqual('first');
97 expect(fields.key2.get()).toEqual('second');
137 }); 98 });
138
139 }); 99 });
140 100
141 describe('panels generated from Barricade.MutableObject (non-permanent panels)', function() { 101 describe('keyExtractor function expectations', function() {
142 var topLevelObj; 102 var collectedFields, container, panels;
143
144 beforeEach(function() { 103 beforeEach(function() {
145 topLevelObj = fields.frozendict.extend({}, { 104 collectedFields = {};
146 'key2': {
147 '@class': fields.dictionary.extend({}, {
148 '@meta': {
149 'panelIndex': 0
150 },
151 '?': {
152 '@class': fields.frozendict.extend({}, {
153 'name': {'@class': fields.string}
154 })
155 }
156 })
157 }
158 }).create();
159 }); 105 });
160 106
161 it('are given a separate panel for each MutableObject entry', function() { 107 it('only fields for which it returns a numeric value get to the panel', function() {
162 var panels; 108 container = simpleContainerCls.create({'key1': 'first', 'key2': 'second'});
163 topLevelObj.set('key2', { 109 function extractor(field) {
164 'id1': {'name': 'String1'}, 110 return field.get() == 'first' && 1;
165 'id2': {'name': 'String2'} 111 }
112
113 panels = extractPanels(container, extractor);
114 panels[0].each(function(key, field) {
115 collectedFields[key] = field;
166 }); 116 });
167 panels = extractPanels(topLevelObj);
168 117
169 expect(panels.length).toBe(2); 118 expect(collectedFields.key1).toBeDefined();
119 expect(collectedFields.key2).not.toBeDefined();
170 }); 120 });
171 121
172 describe('', function() { 122 it('container fields yielding numeric value get to panel as is', function() {
173 var panels; 123 container = complexContainerCls.create({
174 124 'containerKey': {'container1': {'key1': 'first', 'key2': 'second'}}
175 beforeEach(function() {
176 topLevelObj.set('key2', {'id1': {'name': 'some name'}});
177 panels = extractPanels(topLevelObj);
178 }); 125 });
126 function extractor(field) {
127 return field.instanceof(simpleContainerCls) && 1;
128 }
179 129
180 it('have their title exposed via .title() which mirrors their id', function() { 130 panels = extractPanels(container, extractor);
181 expect(panels[0].title()).toBe('id1'); 131 panels[0].each(function(key, field) {
132 collectedFields[key] = field;
182 }); 133 });
183 134
184 it("panel's title() acts also as a setter of the underlying object id", function() { 135 expect(panels.length).toBe(1);
185 panels[0].title('id2'); 136 expect(collectedFields.key1.get()).toEqual('first');
137 expect(collectedFields.key2.get()).toEqual('second');
186 138
187 expect(panels[0].title()).toBe('id2'); 139 });
188 expect(topLevelObj.get('key2').getByID('id2')).toBeDefined();
189 });
190 140
191 it('are removable (thus are not permanent)', function() { 141 it('same numeric value puts the fields into the same (aggregate) panel', function() {
192 expect(panels[0].removable).toBe(true); 142 container = complexContainerCls.create(
193 }); 143 {
144 'numberKey': 10,
145 'stringKey': 'some'
146 });
194 147
195 it('remove() function actually removes a panel', function() { 148 function extractor(field) {
196 panels[0].remove(); 149 return field.instanceof(simpleContainerCls) ? null : 1;
197 panels = extractPanels(topLevelObj); 150 }
198 151
199 expect(panels.length).toBe(0); 152 panels = extractPanels(container, extractor);
153 panels[0].each(function(key, field) {
154 collectedFields[key] = field;
200 }); 155 });
201 156
157 expect(panels.length).toBe(1);
158 expect(collectedFields.numberKey.get()).toEqual(10);
159 expect(collectedFields.stringKey.get()).toEqual('some');
202 }); 160 });
203 161
204 }); 162 it('panels are ordered by extracted numeric value', function() {
205 163 collectedFields = [];
206 describe('panels generated from objects other than Barricade.MutableObject (permanent panels)', function() { 164 container = complexContainerCls.create({
207 it('have fields marked with the same `panelIndex` in the one panel', function() { 165 'numberKey': 10,
208 var immutableObj = fields.frozendict.extend({}, { 166 'stringKey': 'some'
209 'key1': { 167 });
210 '@class': fields.string.extend({}, { 168 function extractor(field) {
211 '@meta': { 169 if (field.instanceof(fields.number)) {
212 'panelIndex': 23 170 return 1;
213 } 171 } else if (field.instanceof(fields.string)) {
214 }) 172 return 2;
215 },
216 'key2': {
217 '@class': fields.string.extend({}, {
218 '@meta': {
219 'panelIndex': 23
220 }
221 })
222 },
223 'key3': {
224 '@class': fields.string.extend({}, {
225 '@meta': {
226 'panelIndex': 25
227 }
228 })
229 } 173 }
230 }).create(), 174 }
231 panels = extractPanels(immutableObj);
232 175
233 expect(panels.length).toBe(2); 176 panels = extractPanels(container, extractor);
234 expect(panels[0].items.panelIndex).toBe(23); 177 panels.forEach(function(panel, index) {
178 collectedFields[index] = {};
179 panel.each(function(key, field) {
180 collectedFields[index][key] = field;
181 });
182 });
235 183
184 expect(panels.length).toBe(2);
185 expect(collectedFields[0].numberKey.get()).toEqual(10);
186 expect(collectedFields[1].stringKey.get()).toEqual('some');
236 }); 187 });
237 188
238 it('number of panels is defined by number of different `panelIndex` keys', function() { 189 it('second argument to extractor is the parent container', function() {
239 var immutableObj = fields.frozendict.extend({}, { 190 collectedFields = [];
240 'key1': { 191 container = complexContainerCls.create({
241 '@class': fields.string.extend({}, { 192 'containerKey': {
242 '@meta': { 193 'container1': {'key1': 'first', 'key2': 'second'},
243 'panelIndex': 23 194 'container2': {'key1': 'third', 'key2': 'fourth'}
244 }
245 })
246 },
247 'key2': {
248 '@class': fields.string.extend({}, {
249 '@meta': {
250 'panelIndex': 24
251 }
252 })
253 },
254 'key3': {
255 '@class': fields.string.extend({}, {
256 '@meta': {
257 'panelIndex': 25
258 }
259 })
260 } 195 }
261 }).create(), 196 });
262 panels = extractPanels(immutableObj); 197 function extractor(field, parent) {
263 198 if (field.instanceof(simpleContainerCls)) {
264 expect(panels.length).toBe(3); 199 return 10 + parent.toArray().indexOf(field);
265 }); 200 } else if (field.instanceof(Barricade.Container)) {
266 201 return null;
267 it('are ordered by the `panelIndex` ascension', function() {
268 var immutableObj = fields.frozendict.extend({}, {
269 'key1': {
270 '@class': fields.string.extend({}, {
271 '@meta': {
272 'panelIndex': 25
273 }
274 })
275 },
276 'key2': {
277 '@class': fields.string.extend({}, {
278 '@meta': {
279 'panelIndex': 24
280 }
281 })
282 },
283 'key3': {
284 '@class': fields.string.extend({}, {
285 '@meta': {
286 'panelIndex': 23
287 }
288 })
289 } 202 }
290 }).create(), 203 }
291 panels = extractPanels(immutableObj);
292
293 expect(panels[0].items.panelIndex).toBe(23);
294 expect(panels[1].items.panelIndex).toBe(24);
295 expect(panels[2].items.panelIndex).toBe(25);
296 });
297 204
298 it('have no title returned from .getTitle()', function() { 205 panels = extractPanels(container, extractor);
299 var immutableObj = fields.frozendict.extend({}, { 206 panels.forEach(function(panel, index) {
300 'key1': { 207 collectedFields[index] = {};
301 '@class': fields.string.extend({}, { 208 panel.each(function(key, field) {
302 '@meta': { 209 collectedFields[index][key] = field;
303 'panelIndex': 25 210 });
304 } 211 });
305 })
306 }
307 }).create(),
308 panels = extractPanels(immutableObj);
309 212
310 expect(panels[0].title()).toBeUndefined(); 213 expect(panels.length).toBe(2);
214 expect(collectedFields[0].key1.get()).toEqual('first');
215 expect(collectedFields[0].key2.get()).toEqual('second');
216 expect(collectedFields[1].key1.get()).toEqual('third');
217 expect(collectedFields[1].key2.get()).toEqual('fourth');
311 }); 218 });
312
313 it('are not removable (thus are permanent)', function() {
314 var immutableObj = fields.frozendict.extend({}, {
315 'key1': {
316 '@class': fields.string.extend({}, {
317 '@meta': {
318 'panelIndex': 25
319 }
320 })
321 }
322 }).create(),
323 panels = extractPanels(immutableObj);
324
325 expect(panels[0].removable).toBeUndefined();
326 })
327
328 }); 219 });
329 220
330 describe('panels are cached,', function() { 221 describe('panels are cached,', function() {
331 var immutableObj; 222 var container, panels1, panels2;
332
333 beforeEach(function() { 223 beforeEach(function() {
334 immutableObj = fields.frozendict.extend({}, { 224 container = complexContainerCls.create({
335 'key1': { 225 'numberKey': 10,
336 '@class': fields.string 226 'stringKey': 'some',
337 } 227 'containerKey': {
338 }).create(); 228 'container1': {'key1': 'first', 'key2': 'second'},
339 }); 229 'container2': {'key1': 'third', 'key2': 'fourth'}
340
341 it('and 2 consequent filter calls return the identical results', function() {
342 var panels1, panels2;
343
344 immutableObj.get('key1').set('String_1');
345 panels1 = extractPanels(immutableObj);
346 panels2 = extractPanels(immutableObj);
347
348 expect(panels1).toBe(panels2);
349 });
350
351 it("still totally replacing the elements that go to permanent panels doesn't reset the cache", function() {
352 var panels1, panels2;
353
354 immutableObj.get('key1').set('String_1');
355 panels1 = extractPanels(immutableObj);
356 immutableObj.get('key1').set('String_2');
357 panels2 = extractPanels(immutableObj);
358
359 expect(panels1).toBe(panels2);
360 });
361
362 it('while totally replacing the top-level object of a non-permanent panel resets the cache', function() {
363 var immutableObj = fields.frozendict.extend({}, {
364 'key2': {
365 '@class': fields.dictionary.extend({}, {
366 '@meta': {
367 'panelIndex': 0
368 },
369 '?': {
370 '@class': fields.frozendict.extend({}, {
371 'key1': {'@class': fields.string}
372 })
373 }
374 })
375 } 230 }
376 }).create(), 231 });
377 panels1, panels2;
378
379 immutableObj.set('key2', {'id_1': {key1: 'String_1'}});
380 panels1 = extractPanels(immutableObj);
381
382 immutableObj.get('key2').removeItem('id_1');
383 immutableObj.set('key2', {'id_1': {key1: 'String_1'}});
384 panels2 = extractPanels(immutableObj);
385 232
386 expect(panels1).not.toBe(panels2);
387 }); 233 });
388 234
389 it("but totally replacing the object contained within top-level object of a " + 235 it('and 2 consequent filter calls return the identical results', function() {
390 "non-permanent panel doesn't reset the cache", function() { 236 function extractor(field, parent) {
391 var immutableObj = fields.frozendict.extend({}, { 237 if (field.instanceof(simpleContainerCls)) {
392 'key2': { 238 return 10 + parent.toArray().indexOf(field);
393 '@class': fields.dictionary.extend({}, { 239 } else if (field.instanceof(Barricade.Container)) {
394 '@meta': { 240 return null;
395 'panelIndex': 0 241 } else {
396 }, 242 return 0;
397 '?': {
398 '@class': fields.frozendict.extend({}, {
399 'key1': {'@class': fields.string}
400 })
401 }
402 })
403 } 243 }
404 }).create(), 244 }
405 panels1, panels2;
406
407 immutableObj.set('key2', {'id_1': {key1: 'String_1'}});
408 panels1 = extractPanels(immutableObj);
409 245
410 immutableObj.get('key2').getByID('id_1').get('key1').set('String_2'); 246 panels1 = extractPanels(container, extractor);
411 panels2 = extractPanels(immutableObj); 247 panels2 = extractPanels(container, extractor);
412 248
413 expect(panels1).toBe(panels2); 249 expect(panels1).toBe(panels2);
414 }); 250 });
415 251
416 }); 252 describe('yet adding/removing entity tracked by keyExtractor causes panels recalculation', function() {
417 253 it('the change is being tracked by extractor', function() {
418 }); 254 function extractor(field, parent) {
419 255 if (field.instanceof(simpleContainerCls)) {
420 describe('extractRows() behavior:', function() { 256 return 10 + parent.toArray().indexOf(field);
421 var extractRows, extractPanels; 257 } else if (field.instanceof(Barricade.Container)) {
422 258 return null;
423 beforeEach(function() { 259 } else {
424 extractPanels = $filter('extractPanels'); 260 return 0;
425 extractRows = $filter('extractRows'); 261 }
426 });
427
428 describe('the filter is meant to be chainable', function() {
429 var immutableObj;
430
431 beforeEach(function() {
432 immutableObj = fields.frozendict.extend({}, {
433 'key1': {
434 '@class': fields.number.extend({}, {
435 '@meta': {
436 'row': 0
437 }
438 })
439 } 262 }
440 }).create();
441 });
442
443 it('with extractPanels() results', function() {
444 var firstPanel = extractPanels(immutableObj)[0],
445 rows = extractRows(firstPanel);
446
447 expect(rows.length).toBe(1);
448 });
449 263
450 it('with Barricade.ImmutableObject contents', function() { 264 panels1 = extractPanels(container, extractor);
451 var rows = extractRows(immutableObj); 265 container.get('containerKey').push(
266 {'key1': 'fifth', 'key2': 'sixth'}, {id: 'container3'});
267 panels2 = extractPanels(container, extractor);
452 268
453 expect(rows.length).toBe(1); 269 expect(panels1).not.toBe(panels2);
454 }); 270 });
455 271
456 it('even with Barricade.MutableObject contents', function() { 272 it('the same change is not being tracked by a different extractor', function() {
457 var mutableObj = fields.dictionary.extend({}, { 273 function extractor(field) {
458 '?': { 274 if (field.instanceof(fields.dictionary)) {
459 '@class': fields.string.extend({}, { 275 return 10;
460 '@meta': {'row': 0} 276 } else if (field.instanceof(Barricade.Container)) {
461 }) 277 return null;
278 } else {
279 return 0;
462 } 280 }
463 }).create(), 281 }
464 rows;
465 282
466 mutableObj.push('string1', {id: 'id1'}); 283 panels1 = extractPanels(container, extractor);
467 mutableObj.push('string2', {id: 'id2'}); 284 container.get('containerKey').push(
468 rows = extractRows(mutableObj); 285 {'key1': 'fifth', 'key2': 'sixth'}, {id: 'container3'});
286 panels2 = extractPanels(container, extractor);
469 287
470 expect(rows.length).toBe(1); 288 expect(panels1).toBe(panels2);
289 });
471 }); 290 });
472
473 }); 291 });
474 292
475 it("the filter is not meant to be chainable with Barricade " + 293 describe('other properties', function() {
476 "objects other MutableObject or ImmutableObject", function() { 294 var simpleContainerCls1, simpleContainerCls2,
477 function test() { 295 complexContainerCls, obj, panels;
478 var arrayObj = fields.list.extend({}, { 296
479 '*': { 297 function extractor(field, parent) {
480 '@class': fields.string.extend({}, { 298 var key;
481 '@meta': { 299 if (field.instanceof(simpleContainerCls1)) {
482 'row': 0 300 return 20 + parent.toArray().indexOf(field);
483 } 301 } else if (field.instanceof(simpleContainerCls2)) {
484 }) 302 key = parent.getKeys()[0];
303 parent.each(function(itemKey, item) {
304 if (item === field) {
305 key = itemKey;
485 } 306 }
486 }).create(); 307 });
487 308 return 10 + parent.getKeys().indexOf(key);
488 arrayObj.push('string1'); 309 } else if (field.instanceof(Barricade.Container)) {
489 arrayObj.push('string2'); 310 return null;
490 return extractRows(arrayObj); 311 } else {
312 return 0;
313 }
491 } 314 }
492 315
493 expect(test).toThrow(); 316 beforeEach(function() {
494 }); 317 simpleContainerCls1 = fields.frozendict.extend({}, {
495 318 'key1': {'@class': fields.string},
496 319 'key2': {'@class': fields.string}
497 describe('the filter relies upon `@meta` object with `row` key', function() { 320 });
498 it('and all fields without it are put into the same row', function() {
499 var immutableObj = fields.frozendict.extend({}, {
500 'key1': {
501 '@class': fields.string
502 },
503 'key2': {
504 '@class': fields.string
505 }
506 }).create(),
507 rows = extractRows(immutableObj);
508 321
509 expect(rows.length).toBe(1); 322 simpleContainerCls2 = fields.frozendict.extend({}, {
510 }); 323 'key1': {'@class': fields.string},
324 'key2': {'@class': fields.string}
325 });
511 326
512 it('the filter is applied only to the top-level entries of the passed object', function() { 327 complexContainerCls = fields.frozendict.extend({}, {
513 var immutableObj = fields.frozendict.extend({}, { 328 'numberKey': {'@class': fields.number},
514 'key1': { 329 'stringKey': {'@class': fields.string},
515 '@class': fields.string.extend({}, { 330 'fluidContainer': {
516 '@meta': { 331 '@class': fields.dictionary.extend({}, {
517 'row': 1 332 '?': {'@class': simpleContainerCls1}
518 }
519 }) 333 })
520 }, 334 },
521 'key2': { 335 'fixedContainer': {
522 '@class': fields.frozendict.extend({}, { 336 '@class': fields.frozendict.extend({}, {
523 '@meta': { 337 'container2': {'@class': simpleContainerCls2}
524 'row': 2
525 },
526 'key3': {
527 '@class': fields.string.extend({}, {
528 '@meta': {
529 'row': 3
530 }
531 })
532 }
533 }) 338 })
534 } 339 }
535 }).create(), 340 });
536 rows = extractRows(immutableObj);
537
538 expect(rows.length).toBe(2);
539 });
540 341
541 it('2 fields with the same `row` key are placed in the same row', function() { 342 obj = complexContainerCls.create({
542 var immutableObj = fields.frozendict.extend({}, { 343 'numberKey': 10,
543 'key1': { 344 'stringKey': 'a string',
544 '@class': fields.string.extend({}, { 345 'fluidContainer': {
545 '@meta': { 346 'container1': {'key1': 'one', 'key2': 'two'}
546 'row': 0
547 }
548 })
549 }, 347 },
550 'key2': { 348 'fixedContainer': {
551 '@class': fields.string.extend({}, { 349 'container2': {'key1': 'one', 'key2': 'two'}
552 '@meta': {
553 'row': 0
554 }
555 })
556 } 350 }
557 }).create(), 351 });
558 rows = extractRows(immutableObj);
559 352
560 expect(rows.length).toBe(1); 353 panels = extractPanels(obj, extractor);
561 }); 354 });
562 355
563 it('rows are ordered by the `row` key ascension', function() { 356 describe('panel title', function() {
564 var immutableObj = fields.frozendict.extend({}, { 357 it('title is undefined for aggregate panels', function() {
565 'key3': { 358 expect(panels[0].title).not.toBeDefined();
566 '@class': fields.string.extend({}, { 359 });
567 '@meta': {
568 'row': 2
569 }
570 })
571 },
572 'key2': {
573 '@class': fields.string.extend({}, {
574 '@meta': {
575 'row': 1
576 }
577 })
578 },
579 'key1': {
580 '@class': fields.string.extend({}, {
581 '@meta': {
582 'row': 0
583 }
584 })
585 }
586 }).create({key1: 'String_1', key2: 'String_2', key3: 'String_3'}),
587 rows = extractRows(immutableObj);
588
589 expect(rows[0].items[0].get()).toBe('String_1');
590 expect(rows[1].items[0].get()).toBe('String_2');
591 expect(rows[2].items[0].get()).toBe('String_3');
592 });
593 360
594 }); 361 it('simple title is defined for panels derived from ImmutableObject member', function() {
362 expect(panels[1].title).toEqual('container2');
363 });
595 364
596 describe('rows are cached,', function() { 365 describe('getter/setter title is defined for panels derived from MutableObject members', function() {
597 var immutableObj; 366 it('no-args title invocation returns member ID', function() {
367 expect(panels[2].title()).toEqual('container1');
368 });
598 369
599 beforeEach(function () { 370 it('title invocation with args sets new ID for a member', function() {
600 immutableObj = fields.frozendict.extend({}, { 371 panels[2].title('newTitle');
601 'key1': {
602 '@class': fields.frozendict.extend({}, {
603 'key2': {
604 '@class': fields.string.extend({}, {
605 '@meta': {
606 'row': 1
607 }
608 })
609 },
610 'key3': {
611 '@class': fields.string.extend({}, {
612 '@meta': {
613 'row': 1
614 }
615 })
616 }
617 })
618 }
619 }).create({'key1': {'key2': 'string1', 'key3': 'string2'}});
620 });
621 372
622 it('and 2 consequent filter calls return the identical results', function () { 373 expect(panels[2].title()).toEqual('newTitle');
623 var panels = extractPanels(immutableObj), 374 });
624 rows1 = extractRows(panels[0]), 375 })
625 rows2 = extractRows(panels[0]);
626 376
627 expect(rows1).toBe(rows2);
628 }); 377 });
629 378
630 describe('but totally replacing one of the elements that are contained within', function () { 379 describe('panel removal', function() {
631 it("panel resets the cache", function () { 380 it('removable flag is set only for panels derived from MutableObject members', function() {
632 var panels = extractPanels(immutableObj), 381 expect(panels[0].removable).toBe(false);
633 rows1 = extractRows(panels[0]), 382 expect(panels[1].removable).toBe(false);
634 rows2; 383 expect(panels[2].removable).toBe(true);
635
636 immutableObj.set('key1', {'key2': 'string1', 'key3': 'string2'});
637 panels = extractPanels(immutableObj);
638 rows2 = extractRows(panels[0]);
639
640 expect(rows2).not.toBe(rows1);
641 }); 384 });
642 385
643 it("ImmutableObject resets the cache", function () { 386 it('invoking .remove() for removable panel actually removes it', function() {
644 var obj = immutableObj.get('key1'), 387 panels[2].remove();
645 rows1 = extractRows(obj), 388 panels = extractPanels(obj, extractor);
646 rows2;
647
648 obj.set('key2', 'string5');
649 rows2 = extractRows(obj);
650 389
651 expect(rows2).not.toBe(rows1); 390 expect(panels.length).toBe(2);
652 }); 391 });
653 392
654 it("MutableObject resets the cache", function () { 393 it('invoking .remove() for not removable panel does nothing', function() {
655 var mutableObj = fields.dictionary.extend({}, { 394 panels[0].remove();
656 '?': { 395 panels[1].remove();
657 '@class': fields.string.extend({}, { 396 panels = extractPanels(obj, extractor);
658 '@meta': {
659 'row': 0
660 }
661 })
662 }
663 }).create({'id1': 'string1', 'id2': 'string2'}),
664 rows1 = extractRows(mutableObj),
665 rows2;
666
667 mutableObj.removeItem('id1');
668 mutableObj.push('string1', {id: 'id1'});
669 rows2 = extractRows(mutableObj);
670
671 expect(rows2).not.toBe(rows1);
672 });
673
674 it("yet totally replacing the Object somewhere deeper doesn't reset the cache", function () {
675 var immutableObj = fields.frozendict.extend({}, {
676 'key1': {
677 '@class': fields.string.extend({}, {
678 '@meta': {
679 'row': 1
680 }
681 })
682 },
683 'key2': {
684 '@class': fields.frozendict.extend({}, {
685 'key3': {
686 '@class': fields.string.extend({}, {
687 '@meta': {
688 'row': 1
689 }
690 })
691 },
692 'key4': {
693 '@class': fields.string
694 },
695 '@meta': {
696 'row': 1
697 }
698 })
699 }
700 }).create({
701 'key1': 'string1',
702 'key2': {'key3': 'string3', 'key4': 'string4'}
703 }),
704 rows1 = extractRows(immutableObj),
705 rows2;
706
707 immutableObj.get('key2').set('key3', 'string5');
708 rows2 = extractRows(immutableObj);
709 397
710 expect(rows2).toBe(rows1); 398 expect(panels.length).toBe(3);
711 }) 399 });
712 }); 400 });
401
713 }); 402 });
714 403
715 }); 404 });
716 405
717 describe('extractItems() behavior:', function() { 406 describe('extractFields() behavior:', function() {
718 var extractPanels, extractRows, extractItems, immutableObj; 407 var extractFields, obj;
719
720 beforeEach(function() { 408 beforeEach(function() {
721 extractPanels = $filter('extractPanels'); 409 extractFields = $filter('extractFields');
722 extractRows = $filter('extractRows'); 410 obj = {
723 extractItems = $filter('extractItems'); 411 each: function(callback) {
724 immutableObj = fields.frozendict.extend({}, { 412 var key;
725 'key1': { 413 for (key in this) {
726 '@class': fields.string.extend({}, { 414 if (this.hasOwnProperty(key) && key !== 'each') {
727 '@meta': { 415 callback(key, this[key]);
728 'panelIndex': 0,
729 'row': 0,
730 'index': 0
731 } 416 }
732 }) 417 }
733 },
734 'key2': {
735 '@class': fields.string.extend({}, {
736 '@meta': {
737 'panelIndex': 0,
738 'row': 0,
739 'index': 1
740 }
741 })
742 },
743 'key3': {
744 '@class': fields.string.extend({}, {
745 '@meta': {
746 'panelIndex': 0,
747 'row': 0
748 }
749 })
750 },
751 'key4': {
752 '@class': fields.string.extend({}, {
753 '@meta': {
754 'panelIndex': 0,
755 'index': 0
756 }
757 })
758 },
759 'key5': {
760 '@class': fields.string.extend({}, {
761 '@meta': {
762 'panelIndex': 0,
763 'index': 1
764 }
765 })
766 } 418 }
767 }).create({ 419 }
768 'key1': 'string1', 'key2': 'string2', 'key3': 'string3',
769 'key4': 'string4', 'key5': 'string5'
770 });
771 }); 420 });
772 421
773 it('the filter is meant to be chainable only with extractRows() results', function() { 422 describe('basic expectations', function() {
774 var panels = extractPanels(immutableObj), 423 var value1 = {value: 'some', uid: function() { return 1; }};
775 rows = extractRows(panels[0]), 424 var value2 = {value: 'more', uid: function() { return 2; }};
776 items = extractItems(rows[0]); 425 function wrongCall() {
777 426 var wrongObj = {
778 expect(items.length).toBe(3); 427 key1: value1,
779 }); 428 key2: value2
429 };
430 return extractFields(wrongObj);
431 }
432 function properCall() {
433 return extractFields(obj);
434 }
435 beforeEach(function() {
436 obj.key1 = value1;
437 obj.key2 = value2;
438 });
780 439
781 describe('the filter relies upon `@meta` object with `index` key', function() { 440 it('consumes any object implementing .each() method', function() {
782 describe('fields are ordered by the `index` key ascension, this applies', function() { 441 expect(wrongCall).toThrow();
783 it('to the fields with `row` key defined (ordering within a row)', function() { 442 expect(properCall).not.toThrow();
784 var panels = extractPanels(immutableObj), 443 });
785 rows = extractRows(panels[0]),
786 items = extractItems(rows[0]);
787 444
788 expect(items[0].get()).toBe('string1'); 445 it('produces plain JS object', function() {
789 expect(items[1].get()).toBe('string2'); 446 var collectedFields = properCall();
790 });
791 447
792 it('to the fields w/o `row` key defined (ordering of anonymous rows)', function() { 448 expect(collectedFields).toEqual({key1: value1, 'key2': value2});
793 var panels = extractPanels(immutableObj), 449 })
794 rows = extractRows(panels[0]), 450 });
795 items = extractItems(rows[1]);
796 451
797 expect(items[0].get()).toBe('string4'); 452 // TODO: describe caching behavior as soon as fields sorting is added to extractFields
798 expect(items[1].get()).toBe('string5');
799 });
800 });
801 453
802 }); 454 });
803 455
456 describe('chunks() behavior:', function() {
457 // TODO: describe chunks behavior as soon as its responsibilities are fleshed out
458 // (inline propagation? other features? naming?)
804 }); 459 });
460
805}); \ No newline at end of file 461}); \ No newline at end of file
diff --git a/merlin/test/js/merlin.models.spec.js b/merlin/test/js/merlin.models.spec.js
index db120c5..12a0043 100644
--- a/merlin/test/js/merlin.models.spec.js
+++ b/merlin/test/js/merlin.models.spec.js
@@ -47,41 +47,18 @@ describe('merlin models:', function() {
47 return value; 47 return value;
48 } 48 }
49 49
50 function getCacheIDs() {
51 return dictObj.getValues().map(function(item) {
52 return item.getID();
53 });
54 }
55
56 describe('getValues() method', function() {
57 it('caching works from the very beginning', function() {
58 expect(getCacheIDs()).toEqual(['id1', 'id2']);
59 });
60
61 it('keyValue() getter/setter can be used from the start', function() {
62 var value = getValueFromCache('id1');
63
64 expect(value.keyValue()).toBe('id1');
65
66 value.keyValue('id3');
67 expect(value.keyValue()).toBe('id3');
68 expect(dictObj.getByID('id3')).toBeDefined();
69 });
70 });
71
72 describe('add() method', function() { 50 describe('add() method', function() {
73 it('adds an empty value with given key', function() { 51 it('adds an empty value with given key', function() {
74 dictObj.add('id3'); 52 dictObj.add('id3');
75 53
76 expect(dictObj.getByID('id3').get()).toBe(''); 54 expect(dictObj.getByID('id3').get()).toBe('');
77 expect(getCacheIDs()).toEqual(['id1', 'id2', 'id3']);
78 }); 55 });
79 56
80 it('keyValue() getter/setter can be used for added values', function() { 57 it('keyValue() getter/setter can be used for added values', function() {
81 var value; 58 var value;
82 59
83 dictObj.add('id3'); 60 dictObj.add('id3');
84 value = getValueFromCache('id3'); 61 value = dictObj.getByID('id3');
85 62
86 expect(value.keyValue()).toBe('id3'); 63 expect(value.keyValue()).toBe('id3');
87 64
@@ -112,31 +89,28 @@ describe('merlin models:', function() {
112 }); 89 });
113 90
114 describe('empty() method', function() { 91 describe('empty() method', function() {
115 it('removes all entries in model and in cache', function() { 92 it('removes all entries in model', function() {
116 dictObj.empty(); 93 dictObj.empty();
117 94
118 expect(dictObj.getIDs().length).toBe(0); 95 expect(dictObj.getIDs().length).toBe(0);
119 expect(dictObj.getValues().length).toBe(0);
120 }) 96 })
121 }); 97 });
122 98
123 describe('resetKeys() method', function() { 99 describe('resetKeys() method', function() {
124 it('re-sets dictionary contents to given keys, cache included', function() { 100 it('re-sets dictionary contents to given keys', function() {
125 dictObj.resetKeys(['key1', 'key2']); 101 dictObj.resetKeys(['key1', 'key2']);
126 102
127 expect(dictObj.getIDs()).toEqual(['key1', 'key2']); 103 expect(dictObj.getIDs()).toEqual(['key1', 'key2']);
128 expect(dictObj.getByID('key1').get()).toBe(''); 104 expect(dictObj.getByID('key1').get()).toBe('');
129 expect(dictObj.getByID('key2').get()).toBe(''); 105 expect(dictObj.getByID('key2').get()).toBe('');
130 expect(getCacheIDs()).toEqual(['key1', 'key2']);
131 }) 106 })
132 }); 107 });
133 108
134 describe('removeItem() method', function() { 109 describe('removeItem() method', function() {
135 it('removes dictionary entry by key from model and cache', function() { 110 it('removes dictionary entry by key from model', function() {
136 dictObj.removeItem('id1'); 111 dictObj.removeItem('id1');
137 112
138 expect(dictObj.getByID('id1')).toBeUndefined(); 113 expect(dictObj.getByID('id1')).toBeUndefined();
139 expect(getCacheIDs()).toEqual(['id2']);
140 }) 114 })
141 }); 115 });
142 116