Support of settings with multiple text fields in Fuel UI

Added support for the following custom control types:
* text_list
* textarea_list

Implements: blueprint dynamic-fields

Change-Id: I3b8b0068c98cdc5823534ea9721a17a3d1ee92fe
This commit is contained in:
Julia Aranovich 2016-02-05 19:40:03 +03:00
parent 04b4ecc84a
commit 602f4cecb3
10 changed files with 465 additions and 15 deletions

View File

@ -40,6 +40,8 @@ structure includes the following attributes::
regex: regex:
source: "^[A-z0-9]+$" source: "^[A-z0-9]+$"
error: "Invalid data" error: "Invalid data"
min: 1
max: 3
* *label* is a setting title that is displayed on UI * *label* is a setting title that is displayed on UI
* *weight* defines the order in which this setting is displayed in its group. * *weight* defines the order in which this setting is displayed in its group.
@ -55,6 +57,8 @@ structure includes the following attributes::
* *select* - drop-down list * *select* - drop-down list
* *hidden* - invisible input * *hidden* - invisible input
* *file* - file contents input * *file* - file contents input
* *text_list* - multiple sigle line text inputs
* *textarea_list* - multiple multiline text inputs
* *regex* section is applicable for settings of "text" type. "regex.source" * *regex* section is applicable for settings of "text" type. "regex.source"
is used when validating with a regular expression. "regex.error" contains is used when validating with a regular expression. "regex.error" contains
@ -65,6 +69,10 @@ structure includes the following attributes::
* *values* list is needed for settings of "radio" or "select" type to declare * *values* list is needed for settings of "radio" or "select" type to declare
its possible values. Options from "values" list also support dependencies its possible values. Options from "values" list also support dependencies
and conflcits declaration. and conflcits declaration.
* *min* is actual for settings of "text_list" or "textarea_list" type
to declare a minimum input list length for the setting
* *max* is actual for settings of "text_list" or "textarea_list" type
to declare a maximum input list length for the setting
.. _restrictions: .. _restrictions:

View File

@ -81,6 +81,8 @@ attribute_schema = {
'text', 'text',
'textarea', 'textarea',
'file', 'file',
'text_list',
'textarea_list'
] ]
}, },
# 'value': None, # custom validation depending on type # 'value': None, # custom validation depending on type
@ -116,6 +118,23 @@ allowed_values_schema = {
}, },
} }
# Schema with a structure of multiple text fields setting value
multiple_text_fields_schema = {
'value': {
'type': 'array',
'minItems': 1,
'items': {'type': 'string'},
},
'min': {
'type': 'integer',
'minimum': 1,
},
'max': {
'type': 'integer',
'minimum': 1,
}
}
# Additional properties definitions for 'attirbute_schema' # Additional properties definitions for 'attirbute_schema'
# depending on 'type' property # depending on 'type' property
attribute_type_schemas = { attribute_type_schemas = {
@ -144,6 +163,8 @@ attribute_type_schemas = {
'select': allowed_values_schema, 'select': allowed_values_schema,
'text': {'value': {'type': 'string'}}, 'text': {'value': {'type': 'string'}},
'textarea': {'value': {'type': 'string'}}, 'textarea': {'value': {'type': 'string'}},
'text_list': multiple_text_fields_schema,
'textarea_list': multiple_text_fields_schema
} }
vmware_attributes_schema = { vmware_attributes_schema = {

View File

@ -274,6 +274,36 @@ class TestAttributesValidator(BaseTestCase):
AttributesValidator.validate_editable_attributes, AttributesValidator.validate_editable_attributes,
yaml.load(attrs)) yaml.load(attrs))
def test_text_list_value(self):
attrs = '''
editable:
storage:
osd_pool_size:
description: desc
label: OSD Pool Size
type: text_list
value: ['2']
weight: 80
'''
# check that text_list value is a list
self.assertNotRaises(errors.InvalidData,
AttributesValidator.validate_editable_attributes,
yaml.load(attrs))
attrs = '''
editable:
storage:
osd_pool_size:
description: desc
label: OSD Pool Size
type: text_list
value: 2
weight: 80
'''
self.assertRaises(errors.InvalidData,
AttributesValidator.validate_editable_attributes,
yaml.load(attrs))
@patch('nailgun.objects.Cluster.get_updated_editable_attributes') @patch('nailgun.objects.Cluster.get_updated_editable_attributes')
def test_invalid_provisioning_method(self, mock_cluster_attrs): def test_invalid_provisioning_method(self, mock_cluster_attrs):
attrs = {'editable': {'provision': {'method': attrs = {'editable': {'provision': {'method':

View File

@ -245,8 +245,24 @@ class TestAttributesRestriction(base.BaseTestCase):
source: '\S' source: '\S'
error: "Invalid email" error: "Invalid email"
tenant: tenant:
value: "" value: [""]
type: "text" type: "text_list"
regex:
source: '\S'
error: "Invalid tenant name"
another_tenant:
value: ["test"]
type: "text_list"
min: 2
max: 2
regex:
source: '\S'
error: "Invalid tenant name"
another_tenant_2:
value: ["test1", "test2", "test3"]
type: "text_list"
min: 2
max: 2
regex: regex:
source: '\S' source: '\S'
error: "Invalid tenant name" error: "Invalid tenant name"
@ -270,12 +286,17 @@ class TestAttributesRestriction(base.BaseTestCase):
errs = AttributesRestriction.check_data(models, attributes) errs = AttributesRestriction.check_data(models, attributes)
self.assertItemsEqual( self.assertItemsEqual(
errs, ['Invalid username', 'Invalid tenant name']) errs, ['Invalid username', ['Invalid tenant name'],
"Value ['test'] should have at least 2 items",
"Value ['test1', 'test2', 'test3'] "
"should not have more than 2 items"])
def test_check_with_valid_values(self): def test_check_with_valid_values(self):
access = self.attributes_data['editable']['access'] access = self.attributes_data['editable']['access']
access['user']['value'] = 'admin' access['user']['value'] = 'admin'
access['tenant']['value'] = 'test' access['tenant']['value'] = ['test']
access['another_tenant']['value'] = ['test1', 'test2']
access['another_tenant_2']['value'] = ['test1', 'test2']
objects.Cluster.update_attributes( objects.Cluster.update_attributes(
self.cluster, self.attributes_data) self.cluster, self.attributes_data)

View File

@ -258,6 +258,15 @@ class AttributesRestriction(RestrictionBase):
# TODO(apopovych): handle restriction message # TODO(apopovych): handle restriction message
return return
else: else:
attr_type = data.get('type')
if (
attr_type == 'text_list' or
attr_type == 'textarea_list'
):
err = cls.check_fields_length(data)
if err is not None:
yield err
regex_error = cls.validate_regex(data) regex_error = cls.validate_regex(data)
if regex_error is not None: if regex_error is not None:
yield regex_error yield regex_error
@ -277,13 +286,36 @@ class AttributesRestriction(RestrictionBase):
def validate_regex(data): def validate_regex(data):
attr_regex = data.get('regex', {}) attr_regex = data.get('regex', {})
if attr_regex: if attr_regex:
value = data.get('value') attr_value = data.get('value')
if not isinstance(value, basestring):
return ('Value {0} is of invalid type, cannot check '
'regexp'.format(value))
pattern = re.compile(attr_regex.get('source')) pattern = re.compile(attr_regex.get('source'))
if not pattern.search(value): error = attr_regex.get('error')
return attr_regex.get('error')
def test_regex(value, pattern=pattern, error=error):
if not pattern.search(value):
return error
if isinstance(attr_value, six.string_types):
return test_regex(attr_value)
elif isinstance(attr_value, list):
errors = map(test_regex, attr_value)
if compact(errors):
return errors
else:
return ('Value {0} is of invalid type, cannot check '
'regexp'.format(attr_value))
@staticmethod
def check_fields_length(data):
min_items_num = data.get('min')
max_items_num = data.get('max')
attr_value = data.get('value')
if min_items_num is not None and len(attr_value) < min_items_num:
return ('Value {0} should have at least {1} '
'items'.format(attr_value, min_items_num))
if max_items_num is not None and len(attr_value) > max_items_num:
return ('Value {0} should not have more than {1} '
'items'.format(attr_value, max_items_num))
class VmwareAttributesRestriction(RestrictionBase): class VmwareAttributesRestriction(RestrictionBase):

View File

@ -1375,6 +1375,38 @@ input[type=range] {
padding: 0 0 0 @base-indent * 0.5; padding: 0 0 0 @base-indent * 0.5;
} }
} }
.field-list {
float: left;
> div {
float: none;
input, textarea {
margin-bottom: @base-indent / 2;
}
&:last-child input, &:last-child textarea {
margin-bottom: 0;
}
.field-controls {
float: left;
padding-left: @base-indent;
.btn {
padding: 0;
margin: 7px 0 5px;
&.btn-add-field + .btn {
margin-left: @base-indent / 2;
}
}
}
.field-error {
.text-danger;
padding-top: 1px;
}
}
+ .help-block {
max-width: 350px;
}
}
} }
// PAGES // PAGES

View File

@ -0,0 +1,148 @@
/*
* Copyright 2016 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.
**/
import React from 'react';
import ReactTestUtils from 'react-addons-test-utils';
import customControls from 'views/custom_controls';
var control1, control2, control3;
suite('Text_list Control', () => {
setup(() => {
var renderControl = function(value, error) {
return ReactTestUtils.renderIntoDocument(
<customControls.text_list
type='text_list'
name='some_name'
value={value}
label='Some label'
description='Some description'
disabled={false}
onChange={sinon.spy()}
error={error || null}
min={2}
max={4}
/>
);
};
control1 = renderControl(['val1', 'val2']);
control2 = renderControl(['val1', 'val2', 'val3']);
control3 = renderControl(['val1', 'val2', 'val3', 'val4'], [null, 'Invalid data', null, null]);
});
test('Test control render', () => {
assert.equal(
control1.props.min,
2,
'Min prop should be equal to 2 instead of default 1'
);
assert.equal(
control1.props.max,
4,
'Max prop should be equal to 4 instead of default null'
);
assert.equal(
ReactTestUtils.scryRenderedDOMComponentsWithTag(control1, 'input').length,
2,
'Two text inputs are rendered'
);
assert.equal(
ReactTestUtils.scryRenderedDOMComponentsWithClass(control1, 'field-description').length,
1,
'Control description is shown'
);
var checkInputButtons = function(control, addFieldButtonsAmount, removeFieldButtonsAmount) {
assert.equal(
ReactTestUtils.scryRenderedDOMComponentsWithClass(control, 'btn-add-field').length,
addFieldButtonsAmount,
'Add Field buttons amount: ' + addFieldButtonsAmount
);
assert.equal(
ReactTestUtils.scryRenderedDOMComponentsWithClass(control, 'btn-remove-field').length,
removeFieldButtonsAmount,
'Remove Field buttons amount: ' + removeFieldButtonsAmount
);
};
// maximum inputs amount is not reached, so 2 plus buttons expected
// minimum inputs amount is reached, so no minus buttons expected
checkInputButtons(control1, 2, 0);
// maximum inputs amount is not reached, so 3 plus buttons expected
// minimum inputs amount is not reached, so 3 minus buttons expected
checkInputButtons(control2, 3, 3);
// maximum inputs amount is reached, so no plus buttons expected
// minimum inputs amount is not reached, so 4 minus buttons expected
checkInputButtons(control3, 0, 4);
assert.equal(
ReactTestUtils.scryRenderedDOMComponentsWithClass(control3, 'field-description').length,
0,
'Control description is not shown in case of validation errors'
);
assert.equal(
ReactTestUtils.scryRenderedDOMComponentsWithClass(control3, 'field-error').length,
1,
'Validation error is shown for control input'
);
});
test('Test control value change', () => {
var input = control1.refs.input0;
input.value = 'val1_new';
ReactTestUtils.Simulate.change(input);
assert.deepEqual(
control1.props.onChange.args[0][1],
['val1_new', 'val2'],
'Control value is changed'
);
ReactTestUtils.Simulate.click(control1.refs.add0);
assert.deepEqual(
control1.props.onChange.args[1][1],
['val1', '', 'val2'],
'New control value is added'
);
ReactTestUtils.Simulate.click(control2.refs.remove0);
assert.deepEqual(
control2.props.onChange.args[0][1],
['val2', 'val3'],
'The first control value is removed'
);
});
test('Test control validation', () => {
var validateControl = function(value) {
return customControls.text_list.validate({
value: value,
regex: {source: '^[a-z]+$', error: 'Invalid data'}
});
};
assert.equal(
validateControl(['abc']),
null,
'Control has valid value'
);
assert.deepEqual(
validateControl(['abc', '', '123']),
[null, 'Invalid data', 'Invalid data'],
'Control has invalid value'
);
});
});

View File

@ -229,12 +229,13 @@ var SettingSection = React.createClass({
renderCustomControl(options) { renderCustomControl(options) {
var { var {
setting, settingKey, error, isSettingDisabled, showSettingWarning, setting, settingKey, error, isSettingDisabled, showSettingWarning,
settingWarning, CustomControl, path settingWarning, CustomControl, path, settingName
} = options; } = options;
return <CustomControl return <CustomControl
{...setting} {...setting}
{... _.pick(this.props, 'cluster', 'settings', 'configModels')} {... _.pick(this.props, 'cluster', 'settings', 'configModels', 'onChange')}
key={settingKey} key={settingKey}
name={settingName}
path={path} path={path}
error={error} error={error}
disabled={isSettingDisabled} disabled={isSettingDisabled}
@ -361,7 +362,9 @@ var SettingSection = React.createClass({
// support of custom controls // support of custom controls
var CustomControl = customControls[setting.type]; var CustomControl = customControls[setting.type];
if (CustomControl) { if (CustomControl) {
return this.renderCustomControl(_.extend(renderOptions, {CustomControl, path})); return this.renderCustomControl(
_.extend(renderOptions, {CustomControl, path, settingName})
);
} else if (setting.values) { } else if (setting.values) {
return this.renderRadioGroup(_.extend(renderOptions, {settingName})); return this.renderRadioGroup(_.extend(renderOptions, {settingName}));
} else { } else {

View File

@ -327,7 +327,7 @@ var SettingsTab = React.createClass({
cluster={this.props.cluster} cluster={this.props.cluster}
sectionName={sectionName} sectionName={sectionName}
settingsToDisplay={settingsToDisplay} settingsToDisplay={settingsToDisplay}
onChange={_.bind(this.onChange, this, sectionName)} onChange={_.partial(this.onChange, sectionName)}
allocatedRoles={allocatedRoles} allocatedRoles={allocatedRoles}
settings={settings} settings={settings}
makePath={settings.makePath} makePath={settings.makePath}

View File

@ -16,8 +16,9 @@
import _ from 'underscore'; import _ from 'underscore';
import i18n from 'i18n'; import i18n from 'i18n';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom';
import utils from 'utils'; import utils from 'utils';
import {Input} from 'views/controls'; import {Input, Tooltip} from 'views/controls';
var customControls = {}; var customControls = {};
@ -205,4 +206,158 @@ customControls.custom_repo_configuration = React.createClass({
} }
}); });
customControls.text_list = customControls.textarea_list = React.createClass({
statics: {
validate(setting) {
if (!(setting.regex || {}).source) return null;
var regex = new RegExp(setting.regex.source);
var errors = _.map(setting.value,
(value) => value.match(regex) ? null : setting.regex.error
);
return _.compact(errors).length ? errors : null;
}
},
propTypes: {
value: React.PropTypes.arrayOf(React.PropTypes.node).isRequired,
type: React.PropTypes.oneOf(['text_list', 'textarea_list']).isRequired,
name: React.PropTypes.node,
label: React.PropTypes.node,
description: React.PropTypes.node,
error: React.PropTypes.arrayOf(React.PropTypes.node),
disabled: React.PropTypes.bool,
wrapperClassName: React.PropTypes.node,
onChange: React.PropTypes.func,
min: React.PropTypes.number,
max: React.PropTypes.number,
tooltipPlacement: React.PropTypes.oneOf(['left', 'right', 'top', 'bottom']),
tooltipIcon: React.PropTypes.node,
tooltipText: React.PropTypes.node
},
getInitialState() {
return {};
},
getDefaultProps() {
return {
min: 1,
max: null,
tooltipIcon: 'glyphicon-warning-sign',
tooltipPlacement: 'right'
};
},
changeField(index, method = 'change') {
var value = _.clone(this.props.value);
switch (method) {
case 'add':
value.splice(index + 1, 0, '');
this.setState({key: _.now()});
break;
case 'remove':
value.splice(index, 1);
this.setState({key: _.now()});
break;
case 'change':
var input = ReactDOM.findDOMNode(this.refs['input' + index]);
value[index] = input.value;
break;
}
if (this.props.onChange) return this.props.onChange(this.props.name, value);
},
debouncedFieldChange: _.debounce(function(index) {
return this.changeField(index);
}, 200, {leading: true}),
renderMultipleInputControls(index) {
return (
<div className='field-controls'>
{(!this.props.max || this.props.value.length < this.props.max) &&
<button
ref={'add' + index}
className='btn btn-link btn-add-field'
disabled={this.props.disabled}
onClick={() => this.changeField(index, 'add')}
>
<i className='glyphicon glyphicon-plus-sign' />
</button>
}
{this.props.value.length > this.props.min &&
<button
ref={'remove' + index}
className='btn btn-link btn-remove-field'
disabled={this.props.disabled}
onClick={() => this.changeField(index, 'remove')}
>
<i className='glyphicon glyphicon-minus-sign' />
</button>
}
</div>
);
},
renderInput(value, index) {
var error = (this.props.error || [])[index] || null;
var Tag = this.props.type === 'textarea_list' ? 'textarea' : 'input';
return (
<div
key={'input' + index}
className={utils.classNames({'has-error': !_.isNull(error)})}
>
<Tag
{... _.pick(this.props, 'name', 'disabled')}
ref={'input' + index}
type='text'
className='form-control'
onChange={() => this.debouncedFieldChange(index)}
defaultValue={value}
/>
{this.renderMultipleInputControls(index)}
{error &&
<div className='help-block field-error'>{error}</div>
}
</div>
);
},
renderLabel() {
if (!this.props.label) return null;
return (
<label key='label'>
{this.props.label}
{this.props.tooltipText &&
<Tooltip text={this.props.tooltipText} placement={this.props.tooltipPlacement}>
<i className={utils.classNames('glyphicon tooltip-icon', this.props.tooltipIcon)} />
</Tooltip>
}
</label>
);
},
renderDescription() {
if (this.props.error) return null;
return (
<span key='description' className='help-block field-description'>
{this.props.description}
</span>
);
},
renderWrapper(children) {
return (
<div
key={this.state.key}
className={utils.classNames({
'form-group': true,
disabled: this.props.disabled,
[this.props.wrapperClassName]: this.props.wrapperClassName
})}
>
{children}
</div>
);
},
render() {
return this.renderWrapper([
this.renderLabel(),
<div key='field-list' className='field-list'>
{_.map(this.props.value, this.renderInput)}
</div>,
this.renderDescription()
]);
}
});
export default customControls; export default customControls;