Decouple @enum and drop-down widget

Provide a convenience fields.linkedcollection model to handle common
use-case of using @ref in a Mistral WB. Cover it with unit-tests as
well all scenarios of using fields.linkedcollection in MIstral WB.

Change-Id: I97a61262db4cc521b5c230667a49b99701318f3f
Closes-Bug: #1467514
This commit is contained in:
Timur Sufiev 2015-06-25 19:05:35 +03:00
parent 7ccf4f0dd3
commit a5c1c308cf
11 changed files with 374 additions and 274 deletions

View File

@ -110,10 +110,10 @@
base.on('change', function(operation) {
var argsEntry, pos, entry;
if ( operation != 'id' ) {
pos = base._stdActions.getPosByID(base.get());
pos = base._collection.getPosByID(base.get());
if ( pos > -1 ) {
entry = self.get('base-input');
argsEntry = base._stdActions.get(pos);
argsEntry = base._collection.get(pos);
entry.resetKeys(argsEntry.toJSON());
}
}
@ -122,44 +122,15 @@
}
}, {
'base': {
'@class': fields.string.extend({
'@class': fields.linkedcollection.extend({
create: function(json, parameters) {
var self = fields.string.create.call(this, json, parameters),
stdActionsCls = Barricade.create({
'@type': String,
'@ref': {
to: function() {
return fields.StandardActions;
},
needs: function() {
return models.Root;
},
getter: function(data) {
return data.needed.get('standardActions');
}
}
});
self._stdActions = stdActionsCls.create().on(
'replace', function(newValue) {
self._stdActions = newValue;
self._stdActions.on('change', function() {
self._choices = self._stdActions.getIDs();
self.resetValues();
});
self._stdActions.emit('change');
});
return self;
},
_choices: []
parameters = Object.create(parameters);
parameters.toCls = models.StandardActions;
parameters.neededCls = models.Root;
parameters.substitutedEntryID = 'standardActions';
return fields.linkedcollection.create.call(this, json, parameters);
}
}, {
'@enum': function() {
if ( this._stdActions.isPlaceholder() ) {
this.emit('_resolveUp', this._stdActions);
}
return this._choices;
},
'@meta': {
'index': 1,
'row': 0
@ -402,43 +373,15 @@
models.ActionTaskMixin = Barricade.Blueprint.create(function() {
return this.extend({}, {
'action': {
'@class': fields.string.extend({
'@class': fields.linkedcollection.extend({
create: function(json, parameters) {
var self = fields.string.create.call(this, json, parameters),
actionsCls = Barricade.create({
'@type': String,
'@ref': {
to: function() {
return models.Actions;
},
needs: function() {
return models.Workbook;
},
getter: function(data) {
return data.needed.get('actions');
}
}
});
self._actions = actionsCls.create().on(
'replace', function(newValue) {
self._actions = newValue;
self._actions.on('change', function() {
self._choices = self._actions.getIDs();
self.resetValues();
});
self._actions.emit('change');
});
return self;
},
_choices: []
parameters = Object.create(parameters);
parameters.toCls = models.Actions;
parameters.neededCls = models.Workbook;
parameters.substitutedEntryID = 'actions';
return fields.linkedcollection.create.call(this, json, parameters);
}
}, {
'@enum': function() {
if ( this._actions.isPlaceholder() ) {
this.emit('_resolveUp', this._actions);
}
return this._choices;
},
'@meta': {
'row': 0,
'index': 1
@ -451,43 +394,15 @@
models.WorkflowTaskMixin = Barricade.Blueprint.create(function() {
return this.extend({}, {
'workflow': {
'@class': fields.string.extend({
'@class': fields.linkedcollection.extend({
create: function(json, parameters) {
var self = fields.string.create.call(this, json, parameters),
workflowsCls = Barricade.create({
'@type': String,
'@ref': {
to: function() {
return models.Workflows;
},
needs: function() {
return models.Workbook;
},
getter: function(data) {
return data.needed.get('workflows');
}
}
});
self._workflows = workflowsCls.create().on(
'replace', function(newValue) {
self._workflows = newValue;
self._workflows.on('change', function() {
self._choices = self._workflows.getIDs();
self.resetValues();
});
self._workflows.emit('change');
});
return self;
},
_choices: []
parameters = Object.create(parameters);
parameters.toCls = models.Workflows;
parameters.neededCls = models.Workbook;
parameters.substitutedEntryID = 'workflows';
return fields.linkedcollection.create.call(this, json, parameters);
}
}, {
'@enum': function() {
if ( this._workflows.isPlaceholder() ) {
this.emit('_resolveUp', this._workflows);
}
return this._choices;
},
'@meta': {
'row': 0,
'index': 1

View File

@ -0,0 +1,143 @@
/* Copyright (c) 2015 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
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.
*/
describe('together workbook model and controller', function() {
var models, utils, workbook;
beforeEach(function () {
module('mistral');
inject(function ($injector) {
models = $injector.get('mistral.workbook.models');
utils = $injector.get('merlin.utils');
});
workbook = models.Workbook.create();
});
describe('define top-level actions available to user:', function () {
var $scope;
beforeEach(inject(function (_$controller_) {
var $controller = _$controller_;
$scope = {};
$controller('workbookCtrl', {$scope: $scope});
$scope.workbook = workbook;
}));
describe("'Add Action' action", function () {
it('adds a new Action', function () {
$scope.addAction();
expect(workbook.get('actions').get(0)).toBeDefined();
});
it('creates action with predefined name', function () {
$scope.addAction();
expect(workbook.get('actions').get(0).getID()).toBeGreaterThan('');
});
describe('', function () {
var actionID;
beforeEach(inject(function (baseActionID) {
actionID = baseActionID + '1';
}));
it("corresponding JSON has the right key for the Action", function () {
$scope.addAction();
expect(workbook.toJSON({pretty: true}).actions[actionID]).toBeDefined();
});
it("once the Action ID is changed, it's reflected in JSON", function () {
var newID = 'action10';
$scope.addAction();
workbook.get('actions').getByID(actionID).setID(newID);
expect(workbook.toJSON({pretty: true}).actions[actionID]).toBeUndefined();
expect(workbook.toJSON({pretty: true}).actions[newID]).toBeDefined();
});
});
it('creates actions with different names on 2 successive calls', function () {
$scope.addAction();
$scope.addAction();
expect(workbook.get('actions').get(0).getID()).not.toEqual(
workbook.get('actions').get(1).getID())
});
});
describe("'Add Workflow' action", function () {
it('adds a new Workflow', function () {
$scope.addWorkflow();
expect(workbook.get('workflows').get(0)).toBeDefined();
});
describe('', function () {
var workflowID;
beforeEach(inject(function (baseWorkflowID) {
workflowID = baseWorkflowID + '1';
}));
it("corresponding JSON has the right key for the Workflow", function () {
$scope.addWorkflow();
expect(workbook.toJSON({pretty: true}).workflows[workflowID]).toBeDefined();
});
it("once the workflow ID is changed, it's reflected in JSON", function () {
var newID = 'workflow10';
$scope.addWorkflow();
workbook.get('workflows').getByID(workflowID).setID(newID);
expect(workbook.toJSON({pretty: true}).workflows[workflowID]).toBeUndefined();
expect(workbook.toJSON({pretty: true}).workflows[newID]).toBeDefined();
});
});
it('creates workflow with predefined name', function () {
$scope.addWorkflow();
expect(workbook.get('workflows').get(0).getID()).toBeGreaterThan('');
});
it('creates workflows with different names on 2 successive calls', function () {
$scope.addWorkflow();
$scope.addWorkflow();
expect(workbook.get('workflows').get(0).getID()).not.toEqual(
workbook.get('workflows').get(1).getID())
});
});
describe("'Create'/'Modify'/'Cancel' actions", function () {
it('edit causes a request to an api and a return to main page', function () {
});
it('cancel causes just a return to main page', function () {
});
});
})
});

View File

@ -31,6 +31,37 @@ describe('workbook model logic', function() {
return workbook.get('workflows').getByID(workflowID);
}
describe('defines the standard actions getter for Action->Base field:', function() {
var root, action1;
beforeEach(function() {
root = models.Root.create();
root.set('workbook', workbook);
root.set('standardActions', {
'nova.create_server': ['image', 'flavor', 'network_id'],
'neutron.create_network': ['name', 'create_subnet'],
'glance.create_image': ['image_url']
});
workbook.get('actions').add('action1');
action1 = workbook.get('actions').getByID('action1');
});
it('all actions are present as choices for the Base field', function() {
var availableActions = action1.get('base').getValues();
expect(availableActions).toEqual([
'nova.create_server', 'neutron.create_network', 'glance.create_image']);
});
it("'Base Input' field is set to have keys corresponding to 'Base' field value", function() {
action1.get('base').set('nova.create_server');
expect(action1.get('base-input').getIDs()).toEqual(['image', 'flavor', 'network_id']);
action1.get('base').set('neutron.create_network');
expect(action1.get('base-input').getIDs()).toEqual(['name', 'create_subnet']);
});
});
describe('defines workflow structure transformations:', function() {
var workflowID = 'workflow1';
@ -71,7 +102,7 @@ describe('workbook model logic', function() {
}
beforeEach(function() {
workbook.get('workflows').push({name: 'Workflow 1'}, {id: workflowID});
workbook.get('workflows').add(workflowID);
});
describe('', function() {
@ -120,11 +151,30 @@ describe('workbook model logic', function() {
expect(getTask(taskID).instanceof(models.DirectWFTask)).toBe(true);
});
it("changing task type from 'action' to 'workflow' causes proper structure changes", function() {
getTask(taskID).get('type').set('workflow');
it("'action'-based task offers available custom actions for its Action field", function() {
workbook.get('actions').add('action1');
expect(getTask(taskID).get('action').getValues()).toEqual(['action1']);
expect(getTask(taskID).instanceof(models.WorkflowTaskMixin)).toBe(true);
expect(getTask(taskID).instanceof(models.DirectWFTask)).toBe(true);
workbook.get('actions').add('action2');
expect(getTask(taskID).get('action').getValues()).toEqual(['action1', 'action2']);
});
describe("changing task type from 'action' to 'workflow' causes", function() {
beforeEach(function() {
getTask(taskID).get('type').set('workflow');
});
it('proper structure changes', function() {
expect(getTask(taskID).instanceof(models.WorkflowTaskMixin)).toBe(true);
expect(getTask(taskID).instanceof(models.DirectWFTask)).toBe(true);
});
it('and causes the Workflow field to suggest available workflows as choices', function() {
expect(getTask(taskID).get('workflow').getValues()).toEqual(['workflow1']);
workbook.get('workflows').add('workflow2');
expect(getTask(taskID).get('workflow').getValues()).toEqual([workflowID, 'workflow2']);
});
});
it("changing workflow type to 'reverse' causes the proper changes to its tasks", function() {
@ -168,11 +218,30 @@ describe('workbook model logic', function() {
expect(getTask(taskID).instanceof(models.ReverseWFTask)).toBe(true);
});
it("changing task type from 'action' to 'workflow' causes proper structure changes", function() {
getTask(taskID).get('type').set('workflow');
it("'action'-based task offers available custom actions for its Action field", function() {
workbook.get('actions').add('action1');
expect(getTask(taskID).get('action').getValues()).toEqual(['action1']);
expect(getTask(taskID).instanceof(models.WorkflowTaskMixin)).toBe(true);
expect(getTask(taskID).instanceof(models.ReverseWFTask)).toBe(true);
workbook.get('actions').add('action2');
expect(getTask(taskID).get('action').getValues()).toEqual(['action1', 'action2']);
});
describe("changing task type from 'action' to 'workflow' causes", function() {
beforeEach(function() {
getTask(taskID).get('type').set('workflow');
});
it('proper structure changes', function() {
expect(getTask(taskID).instanceof(models.WorkflowTaskMixin)).toBe(true);
expect(getTask(taskID).instanceof(models.ReverseWFTask)).toBe(true);
});
it('and causes the Workflow field to suggest available workflows as choices', function() {
expect(getTask(taskID).get('workflow').getValues()).toEqual(['workflow1']);
workbook.get('workflows').add('workflow2');
expect(getTask(taskID).get('workflow').getValues()).toEqual([workflowID, 'workflow2']);
});
});
it("changing workflow type to 'direct' causes the proper changes to its tasks", function() {
@ -202,118 +271,4 @@ describe('workbook model logic', function() {
});
});
describe('defines top-level actions available to user:', function() {
var $scope;
beforeEach(inject(function(_$controller_) {
var $controller = _$controller_;
$scope = {};
$controller('workbookCtrl', {$scope: $scope});
$scope.workbook = workbook;
}));
describe("'Add Action' action", function() {
it('adds a new Action', function() {
$scope.addAction();
expect(workbook.get('actions').get(0)).toBeDefined();
});
it('creates action with predefined name', function() {
$scope.addAction();
expect(workbook.get('actions').get(0).getID()).toBeGreaterThan('');
});
describe('', function() {
var actionID;
beforeEach(inject(function(baseActionID) {
actionID = baseActionID + '1';
}));
it("corresponding JSON has the right key for the Action", function() {
$scope.addAction();
expect(workbook.toJSON({pretty: true}).actions[actionID]).toBeDefined();
});
it("once the Action ID is changed, it's reflected in JSON", function() {
var newID = 'action10';
$scope.addAction();
workbook.get('actions').getByID(actionID).setID(newID);
expect(workbook.toJSON({pretty: true}).actions[actionID]).toBeUndefined();
expect(workbook.toJSON({pretty: true}).actions[newID]).toBeDefined();
});
});
it('creates actions with different names on 2 successive calls', function() {
$scope.addAction();
$scope.addAction();
expect(workbook.get('actions').get(0).getID()).not.toEqual(
workbook.get('actions').get(1).getID())
});
});
describe("'Add Workflow' action", function() {
it('adds a new Workflow', function() {
$scope.addWorkflow();
expect(workbook.get('workflows').get(0)).toBeDefined();
});
describe('', function() {
var workflowID;
beforeEach(inject(function(baseWorkflowID) {
workflowID = baseWorkflowID + '1';
}));
it("corresponding JSON has the right key for the Workflow", function() {
$scope.addWorkflow();
expect(workbook.toJSON({pretty: true}).workflows[workflowID]).toBeDefined();
});
it("once the workflow ID is changed, it's reflected in JSON", function() {
var newID = 'workflow10';
$scope.addWorkflow();
workbook.get('workflows').getByID(workflowID).setID(newID);
expect(workbook.toJSON({pretty: true}).workflows[workflowID]).toBeUndefined();
expect(workbook.toJSON({pretty: true}).workflows[newID]).toBeDefined();
});
});
it('creates workflow with predefined name', function() {
$scope.addWorkflow();
expect(workbook.get('workflows').get(0).getID()).toBeGreaterThan('');
});
it('creates workflows with different names on 2 successive calls', function() {
$scope.addWorkflow();
$scope.addWorkflow();
expect(workbook.get('workflows').get(0).getID()).not.toEqual(
workbook.get('workflows').get(1).getID())
});
});
describe("'Create'/'Modify'/'Cancel' actions", function() {
it('edit causes a request to an api and a return to main page', function() {
});
it('cancel causes just a return to main page', function() {
});
});
})
});

View File

@ -52,7 +52,8 @@ module.exports = function (config) {
// explicitly require first module definition file to avoid errors
'extensions/mistral/static/mistral/js/mistral.init.js',
'extensions/mistral/static/mistral/js/mistral.*.js',
'extensions/mistral/test/js/*Spec.js'
'extensions/mistral/test/js/*Spec.js',
'extensions/mistral/test/js/*spec.js'
],
preprocessors: {

View File

@ -134,18 +134,6 @@
})
.directive('typedField', ['$compile', 'merlin.templates',
function($compile, templates) {
function updateAutoCompletionDirective(template) {
template.find('input').each(function(index, elem) {
elem = angular.element(elem);
if ( elem.attr('autocompletable') ) {
// process 'autocompletable' attribute only once
elem.removeAttr('autocompletable');
elem.attr('typeahead-editable', true);
elem.attr('typeahead',
"option for option in value.getSuggestions() | filter:$viewValue");
}
});
}
return {
restrict: 'E',
scope: {
@ -154,10 +142,6 @@
},
link: function(scope, element) {
templates.templateReady(scope.type).then(function(template) {
template = angular.element(template);
if ( scope.value.getSuggestions ) {
updateAutoCompletionDirective(template);
}
element.replaceWith($compile(template)(scope));
})
}

View File

@ -10,9 +10,10 @@
return this;
});
var restrictedChoicesMixin = Barricade.Blueprint.create(function() {
var viewChoicesMixin = Barricade.Blueprint.create(function() {
var self = this,
values, labels, items;
dropDownLimit = this._dropDownLimit || 5,
values, labels, items, isDropDown;
function fillItems() {
values = self.getEnumValues();
@ -42,6 +43,15 @@
values = undefined;
};
this.isDropDown = function() {
// what starts its life as being dropdown / not being dropdown
// should remain so forever
if ( angular.isUndefined(isDropDown) ) {
isDropDown = !this.isEmpty() && this.getValues().length < dropDownLimit;
}
return isDropDown;
};
this.setType('choices');
return this;
});
@ -92,11 +102,7 @@
};
wildcardMixin.call(this);
if ( this.getEnumValues ) {
restrictedChoicesMixin.call(this);
}
var autocompletionUrl = utils.getMeta(this, 'autocompletionUrl');
if ( autocompletionUrl ) {
autoCompletionMixin.call(this, autocompletionUrl);
viewChoicesMixin.call(this);
}
return this;
});
@ -114,19 +120,6 @@
}
}, {'@type': String});
var autoCompletionMixin = Barricade.Blueprint.create(function(url) {
var self = this;
this.getSuggestions = function() { return []; };
$http.get(url).success(function(data) {
self.getSuggestions = function() {
return data;
};
});
return this;
});
var textModel = Barricade.Primitive.extend({
create: function(json, parameters) {
var self = Barricade.Primitive.create.call(this, json, parameters);
@ -256,14 +249,55 @@
}
}, {'@type': Object});
var linkedCollectionModel = stringModel.extend({
create: function(json, parameters) {
var self = stringModel.create.call(this, json, parameters),
collectionCls = Barricade.create({
'@type': String,
'@ref': {
to: function() {
return parameters.toCls;
},
needs: function() {
return parameters.neededCls;
},
getter: function(data) {
return data.needed.get(parameters.substitutedEntryID);
}
}
});
self._collection = collectionCls.create().on(
'replace', function(newValue) {
self._collection = newValue;
self._collection.on('change', function() {
self._choices = self._collection.getIDs();
self.resetValues();
});
self._collection.emit('change');
});
return self;
},
_choices: []
}, {
'@enum': function() {
if ( this._collection.isPlaceholder() ) {
this.emit('_resolveUp', this._collection);
}
return this._choices;
}
}
);
return {
string: stringModel,
text: textModel,
number: numberModel,
list: listModel,
linkedcollection: linkedCollectionModel,
dictionary: dictionaryModel,
frozendict: frozendictModel,
autocompletionmixin: autoCompletionMixin,
wildcard: wildcardMixin // use for most general type-checks
};
}])

View File

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

View File

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

View File

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

View File

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

View File

@ -141,4 +141,65 @@ describe('merlin models:', function() {
});
});
describe('linkedCollection field', function() {
var collectionCls, linkedObjCls, linkedObj, lnkField;
beforeEach(function() {
collectionCls = fields.dictionary.extend({}, {
'?': {
'@class': fields.string
}
});
linkedObjCls = fields.frozendict.extend({}, {
'realCollection': {
'@class': collectionCls
},
'linkedField': {
'@class': fields.linkedcollection.extend({
create: function(json, parameters) {
parameters = Object.create(parameters);
parameters.toCls = collectionCls;
parameters.neededCls = linkedObjCls;
parameters.substitutedEntryID = 'realCollection';
return fields.linkedcollection.create.call(this, json, parameters);
},
_dropDownLimit: 4
})
}
});
linkedObj = linkedObjCls.create({'realCollection': {'a': '', 'b': ''}});
lnkField = linkedObj.get('linkedField');
});
it('provides access from @enum values of one field to IDs of another one', function() {
expect(lnkField.getValues()).toEqual(['a', 'b']);
linkedObj.get('realCollection').add('c');
expect(lnkField.getValues()).toEqual(['a', 'b', 'c']);
});
describe('and exposes _collection attribute', function() {
it('in case more complex things need to be done', function() {
expect(lnkField._collection).toBeDefined();
});
it("which is truly initialized after first @enum's .getValues() call", function() {
expect(lnkField._collection.isPlaceholder()).toBe(true);
lnkField.getValues();
expect(lnkField._collection.isPlaceholder()).toBe(false);
expect(lnkField._collection).toBe(linkedObj.get('realCollection'));
});
});
describe('exposes .isDropDown() call due to @enum presense', function() {
it('which always returns false due to deferred nature of linkedField', function() {
expect(lnkField.isDropDown()).toBe(false);
lnkField.getValues();
expect(lnkField.isDropDown()).toBe(false);
});
});
});
});