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:
source: "^[A-z0-9]+$"
error: "Invalid data"
min: 1
max: 3
* *label* is a setting title that is displayed on UI
* *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
* *hidden* - invisible 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"
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
its possible values. Options from "values" list also support dependencies
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:

View File

@ -81,6 +81,8 @@ attribute_schema = {
'text',
'textarea',
'file',
'text_list',
'textarea_list'
]
},
# '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'
# depending on 'type' property
attribute_type_schemas = {
@ -144,6 +163,8 @@ attribute_type_schemas = {
'select': allowed_values_schema,
'text': {'value': {'type': 'string'}},
'textarea': {'value': {'type': 'string'}},
'text_list': multiple_text_fields_schema,
'textarea_list': multiple_text_fields_schema
}
vmware_attributes_schema = {

View File

@ -274,6 +274,36 @@ class TestAttributesValidator(BaseTestCase):
AttributesValidator.validate_editable_attributes,
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')
def test_invalid_provisioning_method(self, mock_cluster_attrs):
attrs = {'editable': {'provision': {'method':

View File

@ -245,8 +245,24 @@ class TestAttributesRestriction(base.BaseTestCase):
source: '\S'
error: "Invalid email"
tenant:
value: ""
type: "text"
value: [""]
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:
source: '\S'
error: "Invalid tenant name"
@ -270,12 +286,17 @@ class TestAttributesRestriction(base.BaseTestCase):
errs = AttributesRestriction.check_data(models, attributes)
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):
access = self.attributes_data['editable']['access']
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(
self.cluster, self.attributes_data)

View File

@ -258,6 +258,15 @@ class AttributesRestriction(RestrictionBase):
# TODO(apopovych): handle restriction message
return
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)
if regex_error is not None:
yield regex_error
@ -277,13 +286,36 @@ class AttributesRestriction(RestrictionBase):
def validate_regex(data):
attr_regex = data.get('regex', {})
if attr_regex:
value = data.get('value')
if not isinstance(value, basestring):
return ('Value {0} is of invalid type, cannot check '
'regexp'.format(value))
attr_value = data.get('value')
pattern = re.compile(attr_regex.get('source'))
if not pattern.search(value):
return attr_regex.get('error')
error = 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):

View File

@ -1375,6 +1375,38 @@ input[type=range] {
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

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

View File

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

View File

@ -16,8 +16,9 @@
import _ from 'underscore';
import i18n from 'i18n';
import React from 'react';
import ReactDOM from 'react-dom';
import utils from 'utils';
import {Input} from 'views/controls';
import {Input, Tooltip} from 'views/controls';
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;