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:
|
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:
|
||||||
|
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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':
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
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 {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in New Issue