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:
parent
04b4ecc84a
commit
602f4cecb3
|
@ -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:
|
||||
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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 {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue