Move an entire workbook to schema

This is a preparation for integrating with Barricade.js

Change-Id: Iba9424ddc8337497d5bb8431c114b1b07902f6b8
This commit is contained in:
Timur Sufiev 2015-01-15 20:53:04 +03:00
parent b408d89815
commit b922fad15b
14 changed files with 650 additions and 372 deletions

View File

@ -9,3 +9,6 @@ ADD_INSTALLED_APPS = ['merlin', 'mistral']
# Python panel class of the PANEL to be added.
ADD_PANEL = 'mistral.panel.MistralPanel'
ADD_ANGULAR_MODULES = ['angular.filter']
ADD_JS_FILES = ['merlin/lib/angular-filter.js']

View File

@ -1,10 +1,16 @@
<div class="panel panel-default merlin-panel">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" data-target="#elem-{$ $id $}" href="#">{$ title $}</a>
<a href="#" ng-click="removable()"><i ng-show="removable" class="fa fa-times-circle pull-right"></i></a></h4>
<div ng-if="title">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" data-target="#elem-{$ $id $}" href="#">{$ title $}</a>
<a href="#" ng-click="onRemove()"><i ng-show="removable" class="fa fa-times-circle pull-right"></i></a></h4>
</div>
<div id="elem-{$ $id $}" class="panel-collapse collapse in">
<div class="panel-body" ng-transclude>
</div>
</div>
</div>
<div id="elem-{$ $id $}" class="panel-collapse collapse in">
<div ng-if="!title">
<div class="panel-body" ng-transclude>
</div>
</div>

View File

@ -1,13 +1,15 @@
<div class="three-columns" ng-repeat="(key, value) in item[spec.name] track by key">
<div class="left-column">
<div class="form-group">
<label for="elem-{$ $id $}.{$ key $}">
<editable value="key" label="New Name"></editable>
</label>
<div class="input-group">
<input id="elem-{$ $id $}.{$ key $}" type="text" class="form-control" ng-model="value">
<i class="fa fa-minus-circle input-group-addon"></i>
<collapsible-group title="{$ spec.title || makeTitle(spec.name) $}" on-add="add(item[spec.name], '')">
<div class="three-columns" ng-repeat="(key, value) in item[spec.name] track by key">
<div class="left-column">
<div class="form-group">
<label for="elem-{$ $id $}.{$ key $}">
<editable value="key" label="New Name"></editable>
</label>
<div class="input-group">
<input id="elem-{$ $id $}.{$ key $}" type="text" class="form-control" ng-model="value">
<i class="fa fa-minus-circle input-group-addon"></i>
</div>
</div>
</div>
</div>
</div>
</collapsible-group>

View File

@ -1,4 +1,4 @@
<collapsible-group title="{$ spec.title || makeTitle(spec.name) $}" additive="false">
<collapsible-group title="{$ spec.title || makeTitle(spec.name) $}">
<div class="three-columns">
<div class="left-column">
<div class="form-group" ng-repeat="(key, item) in item[spec.name] track by key">

View File

@ -0,0 +1,11 @@
<collapsible-group title="{$ spec.title || makeTitle(spec.name) $}" on-add="add(item[spec.name])">
<div ng-repeat="specs in spec.value | groupBy: 'row' | toArray: true | orderBy: '$key' track by specs.$key">
<div ng-class="{'three-columns': specs[0].row !== undefined }">
<div ng-repeat="spec in specs track by spec.name"
ng-class="{'right-column': $odd && isAtomic(spec.type), 'left-column': $even && isAtomic(spec.type)}">
<typed-field></typed-field>
<div class="clearfix" ng-show="$odd"></div>
</div>
</div>
</div>
</collapsible-group>

View File

@ -0,0 +1,4 @@
<div class="form-group">
<label for="elem-{$ $id $}">{$ spec.title || makeTitle(spec.name) $}</label>
<textarea class="form-control" id="elem-{$ $id $}" ng-model="item[spec.name]"></textarea>
</div>

View File

@ -1,4 +1,5 @@
<collapsible-group title="{$ spec.title || makeTitle(spec.name) $}" on-add="add(item[spec.name], {type: 'string', value: '', id: 'varlist'+item[spec.name].length})">
<collapsible-group title="{$ spec.title || makeTitle(spec.name) $}"
on-add="add(item[spec.name], {type: 'string', value: '', id: 'varlist'+item[spec.name].length})">
<div class="three-columns" ng-repeat="subItem in item[spec.name] track by subItem.id"
ng-class="{dictionary: subItem.type == 'dictionary', list: subItem.type == 'list'}">
<div class="left-column">

View File

@ -0,0 +1,18 @@
<collapsible-group title="{$ spec.title || makeTitle(spec.name) $}" on-add="add(item[spec.name], '')">
<div class="three-columns">
<div class="left-column" style="display:none">
<div class="form-group">
<textarea class="form-control">{$ yaqlExpresion $}</textarea>
</div>
</div>
<div class="right-column">
<div class="form-group">
<div class="input-group">
<span class="input-group-addon yaql-condition fa fa-unlock" role="button"></span>
<input type="text" class="form-control" value="{$ value $}">
<span class="input-group-addon fa fa-minus-circle"></span>
</div>
</div>
</div>
</div>
</collapsible-group>

View File

@ -65,33 +65,219 @@
type: 'list',
value: ['', '']
}]
}
]
}],
workflows: [{
id: 'workflow1',
name: 'Workflow1',
base: '', // FIXME
input: [''],
output: [{
id: 'varlist1',
type: 'string',
value: ''
}],
taskDefaults: {
onError: {
type: 'list',
value: ['', '']
},
onSuccess: {
type: 'list',
value: ['']
},
onComplete: {
type: 'list',
value: ['', '']
}
}
}]
};
$scope.schema = {
action: [{
name: 'name',
name: {
type: 'string',
group: 'one'
}, {
name: 'base',
type: 'string',
group: 'one'
}, {
name: 'baseInput',
type: 'frozendict',
group: ''
}, {
name: 'input',
type: 'list',
group: ''
}, {
name: 'output',
type: 'varlist',
group: ''
index: 0,
panelIndex: 0,
row: 0
},
description: {
type: 'text',
index: 1,
panelIndex: 0,
row: 0
},
actions: {
index: 2,
type: 'panel',
multiple: true,
value: {
name: {
type: 'string',
row: 0,
index: 0
},
base: {
type: 'string',
row: 0,
index: 1
},
baseInput: {
type: 'frozendict',
title: 'Base Input',
index: 2
},
input: {
type: 'list',
index: 3
},
output: {
type: 'varlist',
index: 4
}
}
},
workflows: {
index: 3,
type: 'panel',
multiple: true,
value: {
name: {
type: 'string',
index: 0,
row: 0
},
base: {
type: 'string',
index: 1,
row: 0
},
input: {
type: 'list',
index: 2
},
output: {
type: 'varlist',
index: 3
},
taskDefaults: {
type: 'group',
title: 'Task defaults',
additive: false,
index: 4,
value: {
onError: {
type: 'yaqllist',
title: 'On error',
index: 0
},
onSuccess: {
type: 'yaqllist',
title: 'On success',
index: 1
},
onComplete: {
type: 'yaqllist',
title: 'On complete',
index: 2
}
}
},
tasks: {
type: 'group',
index: 5,
value: {
task: {
type: 'group',
additive: false,
multiple: true,
index: 0,
value: {
name: {
type: 'string',
index: 0,
row: 0
},
type: {
type: 'string',
index: 1,
row: 0
},
action: {
type: 'string',
index: 2,
row: 1
},
input: {
type: 'dictionary',
index: 3
},
publish: {
type: 'dictionary',
index: 4
},
onError: {
type: 'yaqllist',
title: 'On error',
index: 5
},
onSuccess: {
type: 'yaqllist',
title: 'On success',
index: 6
},
onComplete: {
type: 'yaqllist',
title: 'On complete',
index: 7
},
policies: {
type: 'group',
additive: false,
index: 8,
value: {
waitBefore: {
type: 'string',
title: 'Wait before',
index: 0,
row: 0
},
waitAfter: {
type: 'string',
title: 'Wait after',
index: 1,
row: 0
},
timeout: {
type: 'string',
index: 2,
row: 1
},
retryCount: {
type: 'string',
title: 'Retry count',
index: 3,
row: 2
},
retryDelay: {
type: 'string',
title: 'Retry delay',
index: 4,
row: 2
},
retryBreakOn: {
type: 'string',
title: 'Retry break on',
index: 5,
row: 3
}
}
}
}
}
}
}
}
}
]
};
$scope.makeTitle = function(str) {
@ -107,7 +293,7 @@
};
$scope.isAtomic = function(type) {
return ['string'].indexOf(type) > -1;
return ['string', 'text'].indexOf(type) > -1;
};
$scope.remove = function(parent, item) {

View File

@ -60,22 +60,25 @@
}
})
.directive('collapsiblePanel', function($parse, defaultSetter) {
.directive('panel', function() {
return {
restrict: 'E',
templateUrl: '/static/mistral/js/angular-templates/collapsible-panel.html',
transclude: true,
scope: {
title: '@',
removable: '&'
onRemove: '&'
},
link: function(scope, element, attrs) {
disableClickDefaultBehaviour(element);
if ( attrs.onRemove ) {
scope.removable = true;
}
}
}
})
.directive('collapsibleGroup', function($parse, defaultSetter) {
.directive('collapsibleGroup', function() {
return {
restrict: 'E',
templateUrl: '/static/mistral/js/angular-templates/collapsible-group.html',

View File

@ -1,324 +1,329 @@
/* Copyright (c) 2014 Mirantis, Inc.
/* Copyright (c) 2014 Mirantis, Inc.
Licensed under the Apache License, Version 2.0 (the "License"); you may
not use this file except in compliance with the License. You may obtain
a copy of the License at
Licensed under the Apache License, Version 2.0 (the "License"); you may
not use this file except in compliance with the License. You may obtain
a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
*/
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
*/
var types = {
Mistral: {},
base: {},
OpenStack: {
// TODO: obtain list of predefined OpenStack actions from Mistral server-side
// for now a stubbed list of predefined actions suffices
actions: ['createInstance', 'terminateInstance']
},
getOpenStackActions: function() {
return this.OpenStack.actions.slice();
}
};
var types = {
Mistral: {},
base: {},
OpenStack: {
// TODO: obtain list of predefined OpenStack actions from Mistral server-side
// for now a stubbed list of predefined actions suffices
actions: ['createInstance', 'terminateInstance']
},
getOpenStackActions: function() {
return this.OpenStack.actions.slice();
}
};
types.base.AcceptsMixin = Barricade.Blueprint.create(function (acceptsList) {
acceptsList = acceptsList || [];
types.base.AcceptsMixin = Barricade.Blueprint.create(function (acceptsList) {
acceptsList = acceptsList || [];
this.getLabels = function() {
return acceptsList.map(function(item) {
return item.label;
})
};
this.getLabels = function() {
return acceptsList.map(function(item) {
return item.label;
})
};
this.getValue = function(label) {
for ( var i = 0; i < acceptsList.length; i++ ) {
if ( acceptsList[i].label === label ) {
return acceptsList[i].value;
}
}
return null;
}
});
this.getValue = function(label) {
for ( var i = 0; i < acceptsList.length; i++ ) {
if ( acceptsList[i].label === label ) {
return acceptsList[i].value;
}
}
return null;
}
});
types.Mistral.Action = Barricade.create({
'@type': Object,
types.Mistral.Action = Barricade.create({
'@type': Object,
'@meta': {'groups': ['panel2']},
'name': {'@type': String},
'base': {
'@type': String,
'@enum': function() {
var predefinedActions = types.getOpenStackActions(),
actions = workbook.get('actions'),
currentItemIndex = actions.length() - 1;
actions.each(function(index, actionItem) {
var name = actionItem.get('name');
if ( index < currentItemIndex && !name.isEmpty() ) {
predefinedActions = predefinedActions.concat(name.get())
}
});
return predefinedActions;
},
'@default': types.getOpenStackActions()[0]
},
'base-input': {
'@type': Object,
'@required': false,
'?': {'@type': String}
}
});
'name': {'@type': String},
'base': {
'@type': String,
'@enum': function() {
var predefinedActions = types.getOpenStackActions(),
actions = workbook.get('actions'),
currentItemIndex = actions.length() - 1;
actions.each(function(index, actionItem) {
var name = actionItem.get('name');
if ( index < currentItemIndex && !name.isEmpty() ) {
predefinedActions = predefinedActions.concat(name.get())
}
});
return predefinedActions;
},
'@default': types.getOpenStackActions()[0]
},
'base-input': {
'@type': Object,
'@required': false,
'?': {'@type': String}
}
});
types.Mistral.Policy = Barricade.create({
'@type': Object,
types.Mistral.Policy = Barricade.create({
'@type': Object,
'wait-before': {
'@type': Number,
'@required': false
},
'wait-after': {
'@type': Number,
'@required': false
},
'retry': {
'@type': Object,
'@required': false,
'count': {'@type': Number},
'delay': {'@type': Number},
'break-on': {
'@type': String,
'@required': false
}
},
'timeout': {
'@type': Number,
'@required': false
}
});
'wait-before': {
'@type': Number,
'@required': false
},
'wait-after': {
'@type': Number,
'@required': false
},
'retry': {
'@type': Object,
'@required': false,
'count': {'@type': Number},
'delay': {'@type': Number},
'break-on': {
'@type': String,
'@required': false
}
},
'timeout': {
'@type': Number,
'@required': false
}
});
types.Mistral.Task = Barricade.create({
'@type': Object,
types.Mistral.Task = Barricade.create({
'@type': Object,
'name': {'@type': String},
'input': {
'@type': Array,
'*': {
'@class': Barricade.Primitive.extend({
'name': 'Parameter'
}, {
'@type': String
})
}
},
'publish': {
'@type': String,
'@required': false
},
'policies': {
'@class': types.Mistral.Policy,
'@required': false
}
});
'name': {'@type': String},
'input': {
'@type': Array,
'*': {
'@class': Barricade.Primitive.extend({
'name': 'Parameter'
}, {
'@type': String
})
}
},
'publish': {
'@type': String,
'@required': false
},
'policies': {
'@class': types.Mistral.Policy,
'@required': false
}
});
types.Mistral.Tasks = Barricade.MutableObject.extend({
create: function(json, parameters) {
var self = Barricade.MutableObject.create.call(this);
types.Mistral.Tasks = Barricade.MutableObject.extend({
create: function(json, parameters) {
var self = Barricade.MutableObject.create.call(this);
function getParentWorkflowType() {
var container = self._container,
workflow;
while ( container ) {
if ( container.instanceof(types.Mistral.Workflow) ) {
workflow = container;
break;
}
container = container._container;
}
return workflow && workflow.get('type').get();
}
function getParentWorkflowType() {
var container = self._container,
workflow;
while ( container ) {
if ( container.instanceof(types.Mistral.Workflow) ) {
workflow = container;
break;
}
container = container._container;
}
return workflow && workflow.get('type').get();
}
var directSpecificData = {
'on-complete': {
'@type': String,
'@required': false
},
'on-success': {
'@type': String,
'@required': false
},
'on-error': {
'@type': String,
'@required': false
}
},
reverseSpecificData = {
'requires': {
'@type': Array,
'*': {
'@class': Barricade.Primitive.extend({
'name': 'Action'
}, {
'@type': String,
'@enum': function() {
var container = this._container,
workflow, task;
while ( container ) {
if ( container.instanceof(types.Mistral.Task) ) {
task = container;
}
if ( container.instanceof(types.Mistral.Workflow) ) {
workflow = container;
break;
}
container = container._container;
}
if ( workflow && task ) {
return workflow.get('tasks').toArray().filter(function(taskItem) {
return !(taskItem === task) && taskItem.get('name').get();
}).map(function(taskItem) {
return taskItem.get('name').get();
});
} else {
return [];
}
}
})
}
}
};
var directSpecificData = {
'on-complete': {
'@type': String,
'@required': false
},
'on-success': {
'@type': String,
'@required': false
},
'on-error': {
'@type': String,
'@required': false
}
},
reverseSpecificData = {
'requires': {
'@type': Array,
'*': {
'@class': Barricade.Primitive.extend({
'name': 'Action'
}, {
'@type': String,
'@enum': function() {
var container = this._container,
workflow, task;
while ( container ) {
if ( container.instanceof(types.Mistral.Task) ) {
task = container;
}
if ( container.instanceof(types.Mistral.Workflow) ) {
workflow = container;
break;
}
container = container._container;
}
if ( workflow && task ) {
return workflow.get('tasks').toArray().filter(function(taskItem) {
return !(taskItem === task) && taskItem.get('name').get();
}).map(function(taskItem) {
return taskItem.get('name').get();
});
} else {
return [];
}
}
})
}
}
};
types.base.AcceptsMixin.call(self, [
{
label: 'Action-based',
value: function() {
var workflowType = getParentWorkflowType();
if ( workflowType === 'direct' ) {
return types.Mistral.ActionTask.extend({}, directSpecificData);
} else if ( workflowType === 'reverse' ) {
return types.Mistral.ActionTask.extend({}, reverseSpecificData);
} else {
return types.Mistral.ActionTask;
}
}
}, {
label: 'Workflow-based',
value: function() {
var workflowType = getParentWorkflowType();
if ( workflowType === 'direct' ) {
return types.Mistral.WorkflowTask.extend({}, directSpecificData);
} else if ( workflowType === 'reverse' ) {
return types.Mistral.WorkflowTask.extend({}, reverseSpecificData);
} else {
return types.Mistral.WorkflowTask;
}
}
}
]);
return self;
}
}, {
'@type': Object,
'?': {'@class': types.Mistral.Task}
});
types.base.AcceptsMixin.call(self, [
{
label: 'Action-based',
value: function() {
var workflowType = getParentWorkflowType();
if ( workflowType === 'direct' ) {
return types.Mistral.ActionTask.extend({}, directSpecificData);
} else if ( workflowType === 'reverse' ) {
return types.Mistral.ActionTask.extend({}, reverseSpecificData);
} else {
return types.Mistral.ActionTask;
}
}
}, {
label: 'Workflow-based',
value: function() {
var workflowType = getParentWorkflowType();
if ( workflowType === 'direct' ) {
return types.Mistral.WorkflowTask.extend({}, directSpecificData);
} else if ( workflowType === 'reverse' ) {
return types.Mistral.WorkflowTask.extend({}, reverseSpecificData);
} else {
return types.Mistral.WorkflowTask;
}
}
}
]);
return self;
}
}, {
'@type': Object,
'?': {'@class': types.Mistral.Task}
});
types.Mistral.WorkflowTask = types.Mistral.Task.extend({},
{
'workflow': {
'@type': String,
'@enum': function() {
var workflows = workbook.get('workflows').toArray();
return workflows.map(function(workflowItem) {
return workflowItem.get('name').get();
}).filter(function (name) {
return name;
});
}
}
});
types.Mistral.WorkflowTask = types.Mistral.Task.extend({},
{
'workflow': {
'@type': String,
'@enum': function() {
var workflows = workbook.get('workflows').toArray();
return workflows.map(function(workflowItem) {
return workflowItem.get('name').get();
}).filter(function (name) {
return name;
});
}
}
});
types.Mistral.ActionTask = types.Mistral.Task.extend({},
{
'action': {
'@type': String,
'@enum': function() {
var predefinedActions = types.getOpenStackActions(),
actions = workbook.get('actions').toArray();
return predefinedActions.concat(actions.map(function(actionItem) {
return actionItem.get('name').get();
}).filter(function(name) {
return name; }
));
}
}
});
types.Mistral.ActionTask = types.Mistral.Task.extend({},
{
'action': {
'@type': String,
'@enum': function() {
var predefinedActions = types.getOpenStackActions(),
actions = workbook.get('actions').toArray();
return predefinedActions.concat(actions.map(function(actionItem) {
return actionItem.get('name').get();
}).filter(function(name) {
return name; }
));
}
}
});
types.Mistral.Workflow = Barricade.create({
'@type': Object,
types.Mistral.Workflow = Barricade.create({
'@type': Object,
'@meta': {'groups': 'panel3'},
'name': {'@type': String},
'type': {
'@type': String,
'@enum': ['reverse', 'direct'],
'@default': 'direct'
},
'input': {
'@type': Array,
'@required': false,
'*': {
'@class': Barricade.Primitive.extend({
'name': 'Primitive'
}, {
'@type': String
})
}
},
'output': {
'@type': String,
'@required': false
},
'task-defaults': {
'@type': Object,
'@required': false,
'on-error': {'@type': String},
'on-success': {'@type': String},
'on-complete': {'@type': String},
'policies': {'@class': types.Mistral.Policy}
},
'tasks': {
'@class': types.Mistral.Tasks
}
'name': {'@type': String},
'type': {
'@type': String,
'@enum': ['reverse', 'direct'],
'@default': 'direct'
},
'input': {
'@type': Array,
'@required': false,
'*': {
'@class': Barricade.Primitive.extend({
'name': 'Primitive'
}, {
'@type': String
})
}
},
'output': {
'@type': String,
'@required': false
},
'task-defaults': {
'@type': Object,
'@required': false,
'on-error': {'@type': String},
'on-success': {'@type': String},
'on-complete': {'@type': String},
'policies': {'@class': types.Mistral.Policy}
},
'tasks': {
'@class': types.Mistral.Tasks
}
});
});
types.Mistral.Workbook = Barricade.create({
'@type': Object,
types.Mistral.Workbook = Barricade.create({
'@type': Object,
'version': {
'@type': Number,
'@default': 2
},
'name': {
'@type': String
},
'description': {
'@type': String,
'@required': false
},
'actions': {
'@type': Object,
'@required': false,
'?': {
'@class': types.Mistral.Action
}
},
'workflows': {
'@type': Object,
'?': {
'@class': types.Mistral.Workflow
}
}
});
'version': {
'@type': Number,
'@meta': {'groups': ['panel1']},
'@default': 2
},
'name': {
'@type': String,
'@meta': {'groups': ['panel1']}
},
'description': {
'@type': String,
'@meta': {'groups': ['panel1']},
'@required': false
},
'actions': {
'@type': Object,
'@required': false,
'?': {
'@class': types.Mistral.Action
}
},
'workflows': {
'@type': Object,
'?': {
'@class': types.Mistral.Workflow
}
}
});

View File

@ -16,7 +16,8 @@
})
.run(function($http, $templateCache) {
var fields = ['dictionary', 'frozendict', 'list', 'string', 'varlist'];
var fields = ['dictionary', 'frozendict', 'list', 'string',
'varlist', 'text', 'group', 'yaqllist'];
fields.forEach(function(field) {
var base = '/static/mistral/js/angular-templates/fields/';
$http.get(base + field + '.html').success(function(templateContent) {
@ -25,4 +26,42 @@
})
})
.filter('prepareSchema', function($filter) {
var toArray = $filter('toArray'),
orderBy = $filter('orderBy');
function schemaToArray(schema) {
return angular.isArray(schema) ? schema : orderBy(
toArray(schema, true), 'index').map(function(item) {
item.name = item.$key;
if ( item.type === 'panel' ) {
item.panelIndex = item.index;
}
if ( item.type === 'panel' || item.type === 'group' ) {
item.value = schemaToArray(item.value);
}
return item;
});
}
return schemaToArray;
})
.filter('normalizePanels', function() {
return function(collection) {
return collection.map(function(panelSpec) {
if ( panelSpec[0].type === 'panel' ) {
var data = panelSpec[0];
panelSpec.length = data.value.length;
for ( var i = 0; i < panelSpec.length; i++ ) {
panelSpec[i] = data.value[i];
}
panelSpec.multiple = data.multiple;
panelSpec.name = data.name;
}
return panelSpec;
});
}
})
})();

View File

@ -51,40 +51,34 @@
<!-- Data panel start -->
<div class="two-panels">
<div class="left-panel">
<!-- root-level parameters -->
<div class="panel panel-default merlin-panel" id="panel0">
<div class="panel-body">
<div class="two-columns">
<div class="left-column">
<div class="form-group">
<label for="workbookName">Name</label>
<input type="text" class="form-control" id="workbookName" placeholder="Workbook1">
</div>
</div>
<div class="right-column">
<div class="form-group">
<label for="workbookDesc">Description</label>
<textarea class="form-control" id="workbookDesc" placeholder="Type a description here"></textarea>
<div ng-repeat="panelSpec in schema | prepareSchema | groupBy: 'panelIndex' | toArray:true | orderBy: '$key' | normalizePanels track by panelSpec.$key">
<panel ng-if="panelSpec.multiple" ng-repeat="item in data[panelSpec.name] track by item.id" title="{$ item.name $}" on-remove="remove('{$ panelSpec.name $}', item)">
<div ng-repeat="specs in panelSpec | groupBy: 'row' | toArray: true | orderBy: '$key' track by specs.$key">
<div ng-class="{'three-columns': specs[0].row !== undefined }">
<div ng-repeat="spec in specs track by spec.name"
ng-class="{'right-column': $even && isAtomic(spec.type), 'left-column': $odd && isAtomic(spec.type)}">
<typed-field></typed-field>
<div class="clearfix" ng-show="$even"></div>
</div>
</div>
</div>
</div>
</panel>
<panel ng-if="!panelSpec.multiple">
<!-- ugly duplication here -->
<div ng-repeat="specs in panelSpec | groupBy: 'row' | toArray: true | orderBy: '$key' track by specs.$key">
<div ng-class="{'two-columns': specs[0].row !== undefined }">
<div ng-repeat="spec in specs track by spec.name"
ng-class="{'right-column': $even && isAtomic(spec.type), 'left-column': $odd && isAtomic(spec.type)}">
<typed-field></typed-field>
<div class="clearfix" ng-show="$even"></div>
</div>
</div>
</div>
</panel>
</div>
<!-- Action added -->
<collapsible-panel ng-controller="actionCtrl" ng-repeat="item in data.actions track by item.id" title="{$ item.name $}" removable="remove('actions', item)">
<div ng-repeat="specs in schema.action | groupBy: 'group' | toArray:true | orderBy: '-$key' track by specs.$key">
<div ng-class="{'three-columns': specs[0].group}">
<div ng-repeat="spec in specs track by spec.name"
ng-class="{'right-column': $even && isAtomic(spec.type), 'left-column': $odd && isAtomic(spec.type)}">
<typed-field></typed-field>
<div class="clearfix" ng-show="$even"></div>
</div>
</div>
</div>
</collapsible-panel>
<!-- panel with workflow -->
<div ng-controller="workflowsCtrl">
<collapsible-panel title="Workflow1" removable="true">
<panel title="Workflow1" removable="true">
<!-- 2 simple inputs in a single row -->
<div class="three-columns">
<div class="left-column">
@ -267,7 +261,7 @@
</collapsible-group>
</collapsible-group>
</collapsible-group>
</collapsible-panel>
</panel>
</div>
</div>
<!-- YAML Panel -->

File diff suppressed because one or more lines are too long