diff --git a/docs/develop/nailgun/customization/settings.rst b/docs/develop/nailgun/customization/settings.rst index c12ce185ca..f062fa694f 100644 --- a/docs/develop/nailgun/customization/settings.rst +++ b/docs/develop/nailgun/customization/settings.rst @@ -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: diff --git a/nailgun/nailgun/api/v1/validators/json_schema/cluster.py b/nailgun/nailgun/api/v1/validators/json_schema/cluster.py index b1303036aa..5d0ee6a4c3 100644 --- a/nailgun/nailgun/api/v1/validators/json_schema/cluster.py +++ b/nailgun/nailgun/api/v1/validators/json_schema/cluster.py @@ -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 = { diff --git a/nailgun/nailgun/test/unit/test_attributes_validator.py b/nailgun/nailgun/test/unit/test_attributes_validator.py index 049b25664d..8a80b59d0d 100644 --- a/nailgun/nailgun/test/unit/test_attributes_validator.py +++ b/nailgun/nailgun/test/unit/test_attributes_validator.py @@ -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': diff --git a/nailgun/nailgun/test/unit/test_restriction.py b/nailgun/nailgun/test/unit/test_restriction.py index 6d363d6619..dedaefe65f 100644 --- a/nailgun/nailgun/test/unit/test_restriction.py +++ b/nailgun/nailgun/test/unit/test_restriction.py @@ -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) diff --git a/nailgun/nailgun/utils/restrictions.py b/nailgun/nailgun/utils/restrictions.py index f1ea13e95b..14ea9cc90c 100644 --- a/nailgun/nailgun/utils/restrictions.py +++ b/nailgun/nailgun/utils/restrictions.py @@ -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): diff --git a/nailgun/static/styles/main.less b/nailgun/static/styles/main.less index 0f6099c50e..53865af052 100644 --- a/nailgun/static/styles/main.less +++ b/nailgun/static/styles/main.less @@ -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 diff --git a/nailgun/static/tests/unit/text_list_control.js b/nailgun/static/tests/unit/text_list_control.js new file mode 100644 index 0000000000..7038334ce8 --- /dev/null +++ b/nailgun/static/tests/unit/text_list_control.js @@ -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( + + ); + }; + 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' + ); + }); +}); diff --git a/nailgun/static/views/cluster_page_tabs/setting_section.js b/nailgun/static/views/cluster_page_tabs/setting_section.js index aae7032333..f9bb15fffa 100644 --- a/nailgun/static/views/cluster_page_tabs/setting_section.js +++ b/nailgun/static/views/cluster_page_tabs/setting_section.js @@ -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 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 ( +
+ {(!this.props.max || this.props.value.length < this.props.max) && + + } + {this.props.value.length > this.props.min && + + } +
+ ); + }, + renderInput(value, index) { + var error = (this.props.error || [])[index] || null; + var Tag = this.props.type === 'textarea_list' ? 'textarea' : 'input'; + return ( +
+ this.debouncedFieldChange(index)} + defaultValue={value} + /> + {this.renderMultipleInputControls(index)} + {error && +
{error}
+ } +
+ ); + }, + renderLabel() { + if (!this.props.label) return null; + return ( + + ); + }, + renderDescription() { + if (this.props.error) return null; + return ( + + {this.props.description} + + ); + }, + renderWrapper(children) { + return ( +
+ {children} +
+ ); + }, + render() { + return this.renderWrapper([ + this.renderLabel(), +
+ {_.map(this.props.value, this.renderInput)} +
, + this.renderDescription() + ]); + } +}); + export default customControls;