From 80703f78cdce1fd60eaff1ea0344ac6533ece4e8 Mon Sep 17 00:00:00 2001 From: enudler Date: Fri, 18 Sep 2020 11:22:43 +0300 Subject: [PATCH 1/2] fix(config): allow unlimuted text for config values (#394) * fix(config): allow unlimited text for config values #393 --- .circleci/docker-build.sh | 5 ++- .../database/sequelize/sequelizeConnector.js | 2 +- .../migrations/07_config_large.js | 15 +++++++ .../configManager/configHandler-test.js | 42 ++++++++++++++++++- 4 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 src/database/sequlize-handler/migrations/07_config_large.js diff --git a/.circleci/docker-build.sh b/.circleci/docker-build.sh index 8d1df3c19..dbc5f5485 100755 --- a/.circleci/docker-build.sh +++ b/.circleci/docker-build.sh @@ -1,7 +1,7 @@ #!/bin/sh -e if [ "$CIRCLE_BRANCH" != "master" ] ; then - TAG=predator-$CIRCLE_BRANCH + TAG=`echo predator-$CIRCLE_BRANCH | tr -d /` IMAGE=zooz/predator-builds:$TAG else IMAGE=zooz/predator:latest @@ -10,4 +10,5 @@ fi echo "Building Docker image $IMAGE on branch: $CIRCLE_BRANCH" docker build -t $IMAGE . echo $DOCKERHUB_PASSWORD | docker login -u $DOCKERHUB_USERNAME --password-stdin -docker push $IMAGE \ No newline at end of file +docker push $IMAGE + diff --git a/src/configManager/models/database/sequelize/sequelizeConnector.js b/src/configManager/models/database/sequelize/sequelizeConnector.js index b68293c57..e978849c2 100644 --- a/src/configManager/models/database/sequelize/sequelizeConnector.js +++ b/src/configManager/models/database/sequelize/sequelizeConnector.js @@ -65,7 +65,7 @@ async function initSchemas() { primaryKey: true }, value: { - type: Sequelize.DataTypes.STRING + type: Sequelize.DataTypes.TEXT } }); await config.sync(); diff --git a/src/database/sequlize-handler/migrations/07_config_large.js b/src/database/sequlize-handler/migrations/07_config_large.js new file mode 100644 index 000000000..ce57f3568 --- /dev/null +++ b/src/database/sequlize-handler/migrations/07_config_large.js @@ -0,0 +1,15 @@ +const Sequelize = require('sequelize'); + +module.exports.up = async (query, DataTypes) => { + let configTable = await query.describeTable('configs'); + + if (configTable.value) { + await query.changeColumn( + 'configs', 'value', { + type: Sequelize.TEXT + }); + } +}; + +module.exports.down = async (query, DataTypes) => { +}; diff --git a/tests/integration-tests/configManager/configHandler-test.js b/tests/integration-tests/configManager/configHandler-test.js index 14500e95d..5c9547beb 100644 --- a/tests/integration-tests/configManager/configHandler-test.js +++ b/tests/integration-tests/configManager/configHandler-test.js @@ -22,6 +22,7 @@ const defaultBody = { client_errors_ratio: { percentage: 20 }, rps: { percentage: 20 } } + }; const updateBodyWithTypes = { influx_metrics: { @@ -160,6 +161,45 @@ describe('update and get config', () => { }); }); + describe('Update config with large json for custom runner definition', () => { + it('params below minimum', async () => { + let response = await configRequestCreator.updateConfig({ + custom_runner_definition: { + spec: { + template: { + spec: { + containers: [{ + resources: { + limits: { + memory: '512Mi', + cpu: '1' + }, + requests: { + memory: '192Mi', + cpu: '1' + } + } + }], + nodeSelector: { + lifecycle: 'C5nSpot' + }, + tolerations: [ + { + key: 'instances', + operator: 'Equal', + value: 'c5n', + effect: 'NoSchedule' + } + ] + } + } + } + } + }); + should(response.statusCode).eql(200); + }); + }); + describe('Update config validation', () => { it('update config fail with validation require fields', async () => { let response = await configRequestCreator.updateConfig(requestBodyNotValidRequire); @@ -222,7 +262,7 @@ describe('update and get config', () => { it('update config fail with validation type', async () => { let response = await configRequestCreator.updateConfig({ benchmark_threshold: 20, - benchmark_weights: { 'tps': '10' } + benchmark_weights: { tps: '10' } }); should(response.statusCode).eql(400); should(response.body.message).eql(validationError); From c8ddbf85bd0888251d0bbd37b8592dae98b613fb Mon Sep 17 00:00:00 2001 From: manorlh <44364426+manorlh@users.noreply.github.com> Date: Fri, 18 Sep 2020 11:59:16 +0300 Subject: [PATCH 2/2] Ui webhooks (#376) * feat(webhooks): support webhooks in the UI --- ui/src/App/index.js | 4 + ui/src/App/rootSagas.js | 4 +- .../CollapsibleItem/CollapsibleItem.js | 164 ++++----- .../CollapsibleItemHeader.export.js | 173 +++++---- .../CollapsibleItem/styles/Header.scss | 3 +- .../LabeledCheckbox/LabeledCheckbox.export.js | 49 +++ .../LabeledCheckbox/LabeledCheckbox.scss | 25 ++ .../__tests__/CheckboxGroup.test.js | 162 +++++++++ .../MultiSelect/CheckboxGroup/index.js | 74 ++++ .../MultiSelect/MultiSelect.export.js | 336 ++++++++++++++++++ .../MultiSelect/OptionItem/OptionItem.scss | 36 ++ .../MultiSelect/OptionItem/index.js | 33 ++ ui/src/components/MultiSelect/USAGE.md | 69 ++++ .../MultiSelect/style/MultiSelect.scss | 58 +++ ui/src/components/TitleInput/index.js | 14 +- ui/src/components/Utils/Arrays.js | 25 ++ ui/src/components/Utils/DOMEvents.ts | 5 + .../Utils/FilteringStrategies.export.js | 23 ++ ui/src/components/Utils/MinMax.ts | 4 + .../Utils/enhanceChildrenWithProperties.ts | 18 + .../features/components/InfoToolTip/index.js | 27 ++ .../features/components/JobForm/constants.js | 1 + ui/src/features/components/JobForm/index.js | 107 ++++-- ui/src/features/components/SubmitBar/index.js | 14 + ui/src/features/components/TestForm/utils.js | 2 +- .../WebhooksList/CollapsibleWebhook.js | 156 ++++++++ .../components/WebhooksList/WebhookForm.js | 87 +++++ .../components/WebhooksList/constatns.js | 16 + .../features/components/WebhooksList/index.js | 34 ++ .../features/components/WebhooksList/utils.js | 30 ++ ui/src/features/mainMenu.js | 11 +- ui/src/features/redux/action.js | 19 +- .../features/redux/actions/webhooksActions.js | 57 +++ ui/src/features/redux/apis/webhooksApi.js | 33 ++ .../redux/reducers/webhooksReducer.js | 35 ++ ui/src/features/redux/saga/webhooksSagas.js | 60 ++++ .../redux/selectors/webhooksSelector.js | 24 ++ ui/src/features/redux/types/webhooks.js | 20 ++ ui/src/features/webhooks.js | 71 ++++ ui/src/store/reducers.js | 11 +- 40 files changed, 1887 insertions(+), 207 deletions(-) create mode 100644 ui/src/components/LabeledCheckbox/LabeledCheckbox.export.js create mode 100644 ui/src/components/LabeledCheckbox/LabeledCheckbox.scss create mode 100644 ui/src/components/MultiSelect/CheckboxGroup/__tests__/CheckboxGroup.test.js create mode 100644 ui/src/components/MultiSelect/CheckboxGroup/index.js create mode 100644 ui/src/components/MultiSelect/MultiSelect.export.js create mode 100644 ui/src/components/MultiSelect/OptionItem/OptionItem.scss create mode 100644 ui/src/components/MultiSelect/OptionItem/index.js create mode 100644 ui/src/components/MultiSelect/USAGE.md create mode 100644 ui/src/components/MultiSelect/style/MultiSelect.scss create mode 100644 ui/src/components/Utils/Arrays.js create mode 100644 ui/src/components/Utils/DOMEvents.ts create mode 100644 ui/src/components/Utils/FilteringStrategies.export.js create mode 100644 ui/src/components/Utils/MinMax.ts create mode 100644 ui/src/components/Utils/enhanceChildrenWithProperties.ts create mode 100644 ui/src/features/components/InfoToolTip/index.js create mode 100644 ui/src/features/components/SubmitBar/index.js create mode 100644 ui/src/features/components/WebhooksList/CollapsibleWebhook.js create mode 100644 ui/src/features/components/WebhooksList/WebhookForm.js create mode 100644 ui/src/features/components/WebhooksList/constatns.js create mode 100644 ui/src/features/components/WebhooksList/index.js create mode 100644 ui/src/features/components/WebhooksList/utils.js create mode 100644 ui/src/features/redux/actions/webhooksActions.js create mode 100644 ui/src/features/redux/apis/webhooksApi.js create mode 100644 ui/src/features/redux/reducers/webhooksReducer.js create mode 100644 ui/src/features/redux/saga/webhooksSagas.js create mode 100644 ui/src/features/redux/selectors/webhooksSelector.js create mode 100644 ui/src/features/redux/types/webhooks.js create mode 100644 ui/src/features/webhooks.js diff --git a/ui/src/App/index.js b/ui/src/App/index.js index 3e7be769f..31276d20e 100644 --- a/ui/src/App/index.js +++ b/ui/src/App/index.js @@ -6,6 +6,7 @@ import GetReports from '../features/get-last-reports'; import GetTestReports from '../features/get-test-reports'; import Configuration from '../features/get-configuration'; import ReportPage from '../features/report-page'; +import Webhooks from '../features/webhooks'; import { Route, Redirect } from 'react-router'; import { connect } from 'react-redux'; import { ConnectedRouter } from 'react-router-redux'; @@ -48,6 +49,9 @@ class App extends React.Component { ( )} /> + ( + + )} /> ( )} /> diff --git a/ui/src/App/rootSagas.js b/ui/src/App/rootSagas.js index e4dca953f..6271bd80b 100644 --- a/ui/src/App/rootSagas.js +++ b/ui/src/App/rootSagas.js @@ -4,6 +4,7 @@ import { processorsRegister } from '../features/redux/saga/processorsSagas'; import { reportsRegister } from '../features/redux/saga/reportsSagas'; import { jobsRegister } from '../features/redux/saga/jobsSagas'; import { configRegister } from '../features/redux/saga/configSagas'; +import { webhooksRegister } from '../features/redux/saga/webhooksSagas'; export default function * rootSaga () { yield all([ @@ -11,6 +12,7 @@ export default function * rootSaga () { testsRegister(), reportsRegister(), jobsRegister(), - configRegister() + configRegister(), + webhooksRegister() ]); } diff --git a/ui/src/components/CollapsibleItem/CollapsibleItem.js b/ui/src/components/CollapsibleItem/CollapsibleItem.js index 468335429..ee8013a27 100644 --- a/ui/src/components/CollapsibleItem/CollapsibleItem.js +++ b/ui/src/components/CollapsibleItem/CollapsibleItem.js @@ -10,96 +10,98 @@ import Section from './components/CollapsibleItemSection.export' import css from './styles/index.scss' const TYPES = { - DEFAULT: 'DEFAULT', - ERROR: 'ERROR', - SUCCESS: 'SUCCESS', - CLICKER: 'CLICKER' + DEFAULT: 'DEFAULT', + ERROR: 'ERROR', + SUCCESS: 'SUCCESS', + CLICKER: 'CLICKER' } -const { CARD_TYPES } = Card +const {CARD_TYPES} = Card const CollapsibleItem = ({ - icon, - title, - sections, - body, - onClick, - expanded, - disabled, - editable, - toggleable, - className, - titlePrefix, - type, - ...rest -}) => { - const resolvedClassName = classnames(css.collapsible, className, { [css.disabled]: disabled }) - let cardType = CARD_TYPES.DEFAULT - if (type === TYPES.ERROR) { - cardType = CARD_TYPES.ERROR - } else if (type === TYPES.SUCCESS) { - cardType = CARD_TYPES.SUCCESS - } else if (type === TYPES.CLICKER) { - cardType = CARD_TYPES.CLICKER - } else if (disabled) { - cardType = CARD_TYPES.DISABLED - } - return ( - -
- - - ) + icon, + title, + sections, + body, + onClick, + expanded, + disabled, + editable, + toggleable, + className, + titlePrefix, + iconWrapperStyle, + type, + ...rest + }) => { + const resolvedClassName = classnames(css.collapsible, className, {[css.disabled]: disabled}) + let cardType = CARD_TYPES.DEFAULT + if (type === TYPES.ERROR) { + cardType = CARD_TYPES.ERROR + } else if (type === TYPES.SUCCESS) { + cardType = CARD_TYPES.SUCCESS + } else if (type === TYPES.CLICKER) { + cardType = CARD_TYPES.CLICKER + } else if (disabled) { + cardType = CARD_TYPES.DISABLED + } + return ( + +
+ + + ) } CollapsibleItem.propTypes = { - onClick: PropTypes.func, - editable: PropTypes.bool, - expanded: PropTypes.bool, - toggleable: PropTypes.bool, - type: PropTypes.oneOf(Object.values(TYPES)), - disabled: PropTypes.bool, - icon: PropTypes.string, - title: PropTypes.oneOfType([PropTypes.element, PropTypes.string]), - sections: PropTypes.oneOfType([ - PropTypes.element, - PropTypes.arrayOf(PropTypes.element) - ]), - body: PropTypes.element, - className: PropTypes.string, - titlePrefix: PropTypes.array + onClick: PropTypes.func, + editable: PropTypes.bool, + expanded: PropTypes.bool, + toggleable: PropTypes.bool, + type: PropTypes.oneOf(Object.values(TYPES)), + disabled: PropTypes.bool, + icon: PropTypes.string, + title: PropTypes.oneOfType([PropTypes.element, PropTypes.string]), + sections: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.arrayOf(PropTypes.element) + ]), + body: PropTypes.element, + className: PropTypes.string, + titlePrefix: PropTypes.array } CollapsibleItem.defaultProps = { - onClick: _.noop, - type: TYPES.DEFAULT, - editable: false, - expanded: false, - disabled: false, - toggleable: false, - icon: '', - title: null, - sections: null + onClick: _.noop, + type: TYPES.DEFAULT, + editable: false, + expanded: false, + disabled: false, + toggleable: false, + icon: '', + title: null, + sections: null } CollapsibleItem.TYPES = TYPES diff --git a/ui/src/components/CollapsibleItem/components/CollapsibleItemHeader.export.js b/ui/src/components/CollapsibleItem/components/CollapsibleItemHeader.export.js index 25b708d9d..0c80c0719 100644 --- a/ui/src/components/CollapsibleItem/components/CollapsibleItemHeader.export.js +++ b/ui/src/components/CollapsibleItem/components/CollapsibleItemHeader.export.js @@ -1,4 +1,4 @@ -import React, { Component, cloneElement } from 'react' +import React, {Component, cloneElement} from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' import _ from 'lodash' @@ -6,91 +6,110 @@ import _ from 'lodash' import css from '../styles/Header.scss' class Header extends Component { - getToggle = () => { - const { expanded, disabled, toggleable } = this.props - const classes = classnames({ - fa: true, - 'fa-chevron-down': true, - [css.headerToggle]: true, - [css.disabled]: disabled - }) - return toggleable && -
-
-
- }; + getToggle = () => { + const {expanded, disabled, toggleable} = this.props + const classes = classnames({ + fa: true, + 'fa-chevron-down': true, + [css.headerToggle]: true, + [css.disabled]: disabled + }) + return toggleable && +
+
+
+ }; - getIcon = () => { - const { icon, disabled } = this.props - const classes = classnames({ - [css.headerIcon]: true, - [icon]: true, - [css.disabled]: disabled - }) - return icon &&
- }; + getIcon = () => { + const {icon, disabled} = this.props; + if (!icon) { + return; + } + let result = icon; + if (!Array.isArray(icon)) { + result = [icon]; + } - getSections = () => { - const { sections, disabled } = this.props - const classes = disabled ? css.disabled : '' - if (!sections) { - return null - } else if (Array.isArray(sections)) { - return sections.map(section => { - return cloneElement(section, { className: classes }) - }) - } else { - return cloneElement(sections, { className: classes }) + return result.map((icon) => { + const isString = typeof icon === 'string'; + const classes = classnames({ + [css.headerIcon]: true, + [icon]: isString, + [css.disabled]: disabled + }); + const result = isString ?
:
{icon}
; + + return ( +
{result}
+ ) + + }) + + }; + + getSections = () => { + const {sections, disabled} = this.props + const classes = disabled ? css.disabled : '' + + if (!sections) { + return null + } else if (Array.isArray(sections)) { + return sections.map(section => { + return cloneElement(section, {className: classes}) + }) + } else { + return cloneElement(sections, {className: classes}) + } } - } - render () { - const { title, onClick, editable, disabled, className, toggleable, titlePrefix, sections } = this.props - return ( -
-
- {this.getIcon()} - {titlePrefix} - {title} -
-
- {this.getSections()} - {this.getToggle()} -
-
- ) - } + render() { + const {title, iconWrapperStyle = {}, onClick, editable, disabled, className, toggleable, titlePrefix, sections} = this.props + return ( +
+
+
{this.getIcon()}
+ {titlePrefix} + {title} +
+
+ {this.getSections()} + {this.getToggle()} +
+
+ ) + } } + Header.propTypes = { - onClick: PropTypes.func, - editable: PropTypes.bool, - expanded: PropTypes.bool, - toggleable: PropTypes.bool, - disabled: PropTypes.bool, - icon: PropTypes.string, - className: PropTypes.string, - titlePrefix: PropTypes.array, - title: PropTypes.oneOfType([PropTypes.element, PropTypes.string]), - sections: PropTypes.oneOfType([PropTypes.element, PropTypes.arrayOf(PropTypes.element)]) + onClick: PropTypes.func, + editable: PropTypes.bool, + expanded: PropTypes.bool, + toggleable: PropTypes.bool, + disabled: PropTypes.bool, + icon: PropTypes.string || PropTypes.array, + className: PropTypes.string, + titlePrefix: PropTypes.array, + title: PropTypes.oneOfType([PropTypes.element, PropTypes.string]), + sections: PropTypes.oneOfType([PropTypes.element, PropTypes.arrayOf(PropTypes.element)]) } Header.defaultProps = { - onClick: _.noop, - editable: false, - expanded: false, - disabled: false, - toggleable: false, - icon: '', - title: null, - sections: null, - titlePrefix: null + onClick: _.noop, + editable: false, + expanded: false, + disabled: false, + toggleable: false, + icon: '', + title: null, + sections: null, + titlePrefix: null } export default Header diff --git a/ui/src/components/CollapsibleItem/styles/Header.scss b/ui/src/components/CollapsibleItem/styles/Header.scss index a621761dd..5fb4677c9 100644 --- a/ui/src/components/CollapsibleItem/styles/Header.scss +++ b/ui/src/components/CollapsibleItem/styles/Header.scss @@ -39,7 +39,6 @@ font-size: 20px; text-align: left; color: $headerIconColor; - margin-right: 15px; } .headerTitle { @@ -67,4 +66,4 @@ .headerToggle[rotate=true] { transform: rotateX(180deg); -} \ No newline at end of file +} diff --git a/ui/src/components/LabeledCheckbox/LabeledCheckbox.export.js b/ui/src/components/LabeledCheckbox/LabeledCheckbox.export.js new file mode 100644 index 000000000..5ddc2e3f0 --- /dev/null +++ b/ui/src/components/LabeledCheckbox/LabeledCheckbox.export.js @@ -0,0 +1,49 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import css from './LabeledCheckbox.scss' +import Checkbox from '../Checkbox/Checkbox' +import classnames from 'classnames' + +class LabeledCheckbox extends Component { + render () { + const { children, className, checkboxClassName, indeterminate, checked, disabled, onChange, ...rest } = this.props + return ( + !disabled && onChange(!checked)} + > + + + + + + ) + } +} + +LabeledCheckbox.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.arrayOf(PropTypes.node) + ]), + className: PropTypes.string, + indeterminate: PropTypes.bool, + checked: PropTypes.bool, + disabled: PropTypes.bool, + onChange: PropTypes.func, + checkboxClassName: PropTypes.string +} +LabeledCheckbox.defaultProps = { + indeterminate: false, + checked: false, + disabled: false +} + +export default LabeledCheckbox diff --git a/ui/src/components/LabeledCheckbox/LabeledCheckbox.scss b/ui/src/components/LabeledCheckbox/LabeledCheckbox.scss new file mode 100644 index 000000000..87067fb3b --- /dev/null +++ b/ui/src/components/LabeledCheckbox/LabeledCheckbox.scss @@ -0,0 +1,25 @@ +@import "../styles/colors"; + +.wrapper { + display: flex; + align-items: center; + width: 100%; + cursor: pointer; + &.disabled { + cursor: default; + } + + .label_wrapper { + font-size: 13px; + color: $text-black; + cursor: pointer; + position: relative; + bottom: 1px; + flex: 1; + min-width: 0; + } +} + +.input_wrapper { + margin-right: 10px; +} \ No newline at end of file diff --git a/ui/src/components/MultiSelect/CheckboxGroup/__tests__/CheckboxGroup.test.js b/ui/src/components/MultiSelect/CheckboxGroup/__tests__/CheckboxGroup.test.js new file mode 100644 index 000000000..ba24aa7de --- /dev/null +++ b/ui/src/components/MultiSelect/CheckboxGroup/__tests__/CheckboxGroup.test.js @@ -0,0 +1,162 @@ +/* eslint-env jest */ + +import React from 'react' +import { mount } from 'enzyme' +import CheckboxGroup from '..' +import LabeledCheckbox from '../../../../LabeledCheckbox/LabeledCheckbox.export' + +const mountComponent = (props) => { + return mount() +} + +const OPTIONS = [ + { + key: 'key-a', + value: 'value-a' + }, { + key: 'key-b', + value: 'value-b' + }, { + key: 'key-c', + value: 'value-c' + } +] + +describe('', () => { + let checkBoxGroupComponent, defaultProps + + beforeAll(() => { + defaultProps = { + options: OPTIONS, + checkedOptions: [] + } + }) + + describe('PROPS', () => { + describe('Options', () => { + it('Should render an empty group if options is not given', () => { + // Arrange + const props = Object.assign({}, defaultProps, { options: undefined }) + checkBoxGroupComponent = mountComponent(props) + + // Assert + const renderedOptions = checkBoxGroupComponent.find(LabeledCheckbox) + expect(renderedOptions).toHaveLength(0) + }) + it('Should render an empty group if options is an empty array', () => { + // Arrange + const props = Object.assign({}, defaultProps, { options: [] }) + checkBoxGroupComponent = mountComponent(props) + + // Assert + const renderedOptions = checkBoxGroupComponent.find(LabeledCheckbox) + expect(renderedOptions).toHaveLength(0) + }) + it('Should render the given options as a list of LabeledCheckbox components', () => { + // Arrange + checkBoxGroupComponent = mountComponent(defaultProps) + + // Assert + const renderedOptions = checkBoxGroupComponent.find(LabeledCheckbox) + expect(renderedOptions).toHaveLength(OPTIONS.length) + renderedOptions.forEach((option, i) => { + expect(option.text()).toEqual(OPTIONS[i].value) + }) + }) + }) + + describe('CheckedOptions', () => { + it('Should render a list of unchecked LabeledCheckbox components - empty check options', () => { + // Arrange + const props = Object.assign({}, defaultProps, { checkedOptions: undefined }) + checkBoxGroupComponent = mountComponent(props) + + // Assert + const renderedOptions = checkBoxGroupComponent.find(LabeledCheckbox) + renderedOptions.forEach((option) => { + expect(option.prop('checked')).toBe(false) + }) + }) + it('Should render a list of unchecked LabeledCheckbox components', () => { + // Arrange + checkBoxGroupComponent = mountComponent(defaultProps) + + // Assert + const renderedOptions = checkBoxGroupComponent.find(LabeledCheckbox) + renderedOptions.forEach((option) => { + expect(option.prop('checked')).toBe(false) + }) + }) + it('Should render the given checkedOptions as a checked LabeledCheckbox components', () => { + // Arrange + const givenCheckedOption = OPTIONS[0] + const props = Object.assign({}, defaultProps, { checkedOptions: [givenCheckedOption.key] }) + checkBoxGroupComponent = mountComponent(props) + + // Assert + const renderedOptions = checkBoxGroupComponent.find(LabeledCheckbox) + renderedOptions.forEach((option) => { + const isGivenCheckedOption = givenCheckedOption.value.includes(option.text()) + expect(option.prop('checked')).toBe(isGivenCheckedOption) + }) + }) + }) + + describe('ClassName', () => { + it('Should NOT pass any className property to the wrapper component div if className is not given', () => { + // Arrange + checkBoxGroupComponent = mountComponent(defaultProps) + + // Assert + const optionsGroupComponent = checkBoxGroupComponent.find('[data-test="checkbox-group"]') + expect(optionsGroupComponent.prop('className')).toBe(undefined) + }) + + it('Should pass the given className to the wrapper component div', () => { + // Arrange + const givenClassName = 'this-is-className' + const props = Object.assign({}, defaultProps, { className: givenClassName }) + checkBoxGroupComponent = mountComponent(props) + + // Assert + const optionsGroupComponent = checkBoxGroupComponent.find('[data-test="checkbox-group"]') + expect(optionsGroupComponent.prop('className')).toBe(givenClassName) + }) + }) + + describe('OnChange', () => { + it('Should call onChange when option checked status is changed - Selected', () => { + // Arrange + const checkedOptionIndex = 0 + const props = Object.assign({}, defaultProps, { onChange: jest.fn() }) + checkBoxGroupComponent = mountComponent(props) + + // Act - check the first option + const renderedOptions = checkBoxGroupComponent.find(LabeledCheckbox) + renderedOptions.at(checkedOptionIndex).simulate('click') + + // Assert - onChange is called + expect(props.onChange.mock.calls.length).toBe(1) + expect(props.onChange.mock.calls[0][0]).toEqual([OPTIONS[checkedOptionIndex].key]) + }) + it('Should call onChange when option checked status is changed - Unselected', () => { + // Arrange + const checkedOptionIndex = 0 + const props = Object.assign({}, defaultProps, { + checkedOptions: [OPTIONS[checkedOptionIndex].key], + onChange: jest.fn() + }) + checkBoxGroupComponent = mountComponent(props) + + // Act - un-check the first option + const renderedOptions = checkBoxGroupComponent.find(LabeledCheckbox) + expect(renderedOptions.at(checkedOptionIndex).prop('checked')).toBe(true) + renderedOptions.at(checkedOptionIndex).simulate('click') + + // Assert - onChange is called + expect(props.onChange.mock.calls.length).toBe(1) + expect(props.onChange.mock.calls[0][0]).toEqual([]) + }) + }) + }) +}) diff --git a/ui/src/components/MultiSelect/CheckboxGroup/index.js b/ui/src/components/MultiSelect/CheckboxGroup/index.js new file mode 100644 index 000000000..424bfea17 --- /dev/null +++ b/ui/src/components/MultiSelect/CheckboxGroup/index.js @@ -0,0 +1,74 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import _ from 'lodash' + +import OptionItem from '../OptionItem' +import { removeItem, addItem } from '../../Utils/Arrays' + +class CheckboxGroup extends Component { + constructor (props) { + super(props) + this.state = { + checkedOptions: this.props.checkedOptions.map((option) => _.get(option, 'key')) || [] + } + } + + static getDerivedStateFromProps (props) { + return props.checkedOptions ? { checkedOptions: props.checkedOptions } : null + } + + onCheckBoxItemCheck = optionKey => { + const { checkedOptions } = this.state + const isChecked = checkedOptions.includes(optionKey) + + const currentCheckedOptions = isChecked + ? removeItem(checkedOptions, optionKey) + : addItem(checkedOptions, optionKey).sort() + + this.setState({ checkedOptions: currentCheckedOptions }) + + this.props.onChange(currentCheckedOptions) + } + + render () { + const { checkedOptions } = this.state + const { options, className, checkedOptions: checkedOptionsFromProps, ...rest } = this.props + + const buildCheckBoxItems = () => { + return options.map((option, i) => { + const key = _.get(option, 'key') + return ( + this.onCheckBoxItemCheck(key)} + > + {option.value} + + ) + }) + } + + return ( +
+ {buildCheckBoxItems()} +
+ ) + } +} + +CheckboxGroup.propTypes = { + className: PropTypes.string, + options: PropTypes.arrayOf(PropTypes.any).isRequired, + checkedOptions: PropTypes.arrayOf(PropTypes.string), + onChange: PropTypes.func.isRequired +} + +CheckboxGroup.defaultProps = { + options: [], + checkedOptions: [], + onChange: _.noop +} + +export default CheckboxGroup diff --git a/ui/src/components/MultiSelect/MultiSelect.export.js b/ui/src/components/MultiSelect/MultiSelect.export.js new file mode 100644 index 000000000..045c200fd --- /dev/null +++ b/ui/src/components/MultiSelect/MultiSelect.export.js @@ -0,0 +1,336 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import _ from 'lodash' +import classnames from 'classnames' +import style from './style/MultiSelect.scss' +import CheckboxGroup from './CheckboxGroup' +import FontAwesome from '../FontAwesome/FontAwesome.export' +import Chip from '../Chip/index' +import ErrorWrapper from '../ErrorWrapper/index' +import OptionItem from './OptionItem' +import { removeByAttribute, hasItems, areEquals, getFirstN } from '../Utils/Arrays' +import { setFocus } from '../Utils/DOMEvents' +import { startsWithStrategy } from '../Utils/FilteringStrategies.export' +import NoMatches from '../Dropdown/Components/NoMatches' +import Input from '../Dropdown/Components/Input' +import Filter from '../Dropdown/Components/Filter' +import ItemsWrapper from '../Dropdown/Components/ItemsWrapper' +import Placeholder from '../Dropdown/Components/Placeholder' +import ListWrapper from '../Dropdown/Components/ListWrapper' +import DynamicDropdown from '../Dropdown/Components/DynamicDropdown' + +class MultiSelect extends Component { + constructor (props) { + super(props) + const { options = [], selectedOptions = [] } = props + + this.state = { + allOptions: options, + shownOptions: [], + selectedOptions: selectedOptions, + isDropDownListOpen: false, + filteringInputValue: '', + isHovered: false, + isOverflow: false + } + } + + // INIT + componentDidUpdate (prevProps, prevState) { + if (this.props.enableEllipsis && !this.state.isHovered) { + const isOverflow = + this.selectedOptionsInputElement && + this.selectedOptionsInputElement.clientHeight < this.selectedOptionsInputElement.scrollHeight + if (prevState.isOverflow !== isOverflow) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ isOverflow }) + } + } + } + + static getDerivedStateFromProps = (props, state) => { + const newState = {} + + const areOptionsEqual = (options1 = [], options2 = []) => { + return areEquals(options1.map((o) => _.get(o, 'key')), options2.map((o) => _.get(o, 'key'))) + } + + // Options + if (!areOptionsEqual(props.options, state.allOptions)) { + newState.allOptions = props.options + } + + // Selected Options + if (props.selectedOptions !== undefined && !areOptionsEqual(state.selectedOptions, props.selectedOptions)) { + newState.selectedOptions = props.selectedOptions + } + + // Filtering + let shownOptions = [] + if (state.filteringInputValue.length === 0) { + shownOptions = props.options + } else { + const filteredOptions = props.filteringStrategy({ + array: props.options, + propName: 'value', + value: state.filteringInputValue + }) + shownOptions = filteredOptions || [] + } + if (!areOptionsEqual(state.shownOptions, shownOptions)) { + newState.shownOptions = shownOptions + } + + return Object.keys(newState).length > 0 ? newState : null + } + + // Selected-Options-Input Methods + removeItemFromSelectedOptionsInput = item => { + const { state, props } = this + + const selectedOptions = removeByAttribute(state.selectedOptions, 'value', _.get(item, 'value')) + + this.setState({ selectedOptions }) + props.onChange(selectedOptions) + } + + // Options-List Methods + handleOptionListOpen = () => { + this.setState({ + isDropDownListOpen: true, + shownOptions: this.state.allOptions + }) + + setFocus(this.filteringInput) + } + + handleOptionListClose = () => { + this.setState({ isDropDownListOpen: false }) + } + + // Filtering-Input Methods + handleFilteringInputKeyPress = event => { + const { allOptions, shownOptions, selectedOptions = [] } = this.state + + if (event.key.toLowerCase() === 'enter' && shownOptions.length === 1 && !selectedOptions.includes(shownOptions[0])) { + selectedOptions.push(shownOptions[0]) + this.setState({ + shownOptions: allOptions, + selectedOptions, + filteringInputValue: '' + }) + this.props.onChange(selectedOptions) + } + } + + handleFilteringInputKeyDown = event => { + const { isDropDownListOpen } = this.state + const ESCAPE = 27 + + if (event.keyCode === ESCAPE && isDropDownListOpen) { + this.handleOptionListClose() + } + } + + // Options Methods + handleSelectAllOptionChange = checked => { + const selectedOptions = checked ? [...this.state.allOptions] : [] + this.setState({ + selectedOptions: selectedOptions + }) + this.props.onChange(selectedOptions) + } + + handleOptionsSelectionChange = selectedOptionsKeys => { + const selectedOptions = this.state.allOptions.filter((option) => selectedOptionsKeys.includes(_.get(option, 'key'))) + + this.setState({ + selectedOptions + }) + + this.props.onChange(selectedOptions) + setFocus(this.filteringInput) + } + + handleInputKeyDown = (event) => { + const SPACEBAR = 32 + if (event.keyCode === SPACEBAR && !this.state.isDropDownListOpen) { + event.preventDefault() + this.handleOptionListOpen() + } + } + + // RENDERING + render () { + const { + selectedOptions, shownOptions, isDropDownListOpen, + isHovered, isOverflow + } = this.state + const checkAll = this.state.selectedOptions.length === this.state.allOptions.length + + const { + validationErrorText, enableSelectAll, enableEllipsis, selectAllText, + maxSize, enableFilter, placeholder, disabled, height + } = this.props + + let isInputOpen + let isOverflowing = false + if (enableEllipsis) { + if (isHovered && !isDropDownListOpen && isOverflow) { + isInputOpen = true + isOverflowing = true + } else { + isInputOpen = isDropDownListOpen + } + } else { + isOverflowing = true + isInputOpen = isDropDownListOpen + } + + const componentWrapperClassName = classnames(style.wrapper, { [style.ellipsis]: enableEllipsis }) + + const SelectedOptionsInputItems = ({ disabled }) => +
+ {selectedOptions.map((option, index) => ( + this.removeItemFromSelectedOptionsInput(option)} + > + {_.get(option, 'value')} + + ))} +
+ + const buildOptionsListItems = () => { + const options = getFirstN([...shownOptions], maxSize) + return options.map((option) => ({ + value: _.get(option, 'value'), + key: _.get(option, 'key') + })) + } + + const inputComponent = ( + + enableEllipsis && this.setState({ isHovered: true })} + onMouseLeave={() => enableEllipsis && this.setState({ isHovered: false })} + onKeyDown={this.handleInputKeyDown} + ref={input => { this.selectedOptionsInputElement = input }} + height={height} + onClick={this.handleOptionListOpen} + > + <> + {hasItems(selectedOptions) + ? + : {placeholder}} + {(enableEllipsis && !isDropDownListOpen && !isHovered && isOverflow) && + /* Ellipsis Icon */ + } + + + + ) + + const listOptionsComponent = ( + + + {/* filter */} + {enableFilter && + { this.filteringInput = input }} + value={this.state.filteringInputValue} + onChange={(event) => this.setState({ filteringInputValue: event.target.value })} + onKeyPress={this.handleFilteringInputKeyPress} + onKeyDown={this.handleFilteringInputKeyDown} + />} + + {/* Options */} + + {enableSelectAll && ( + + {selectAllText} + + )} + _.get(option, 'key'))} + onChange={this.handleOptionsSelectionChange} + /> + { + shownOptions.length === 0 && + + } + + + ) + + return ( +
+ +
+ ) + } +} + +MultiSelect.propTypes = { + // Data + options: PropTypes.arrayOf(PropTypes.shape({ key: PropTypes.any, value: PropTypes.any })).isRequired, + selectedOptions: PropTypes.arrayOf(PropTypes.shape({ key: PropTypes.any, value: PropTypes.any })), + onChange: PropTypes.func.isRequired, + placeholder: PropTypes.string, + height: PropTypes.string, + disabled: PropTypes.bool, + maxSize: PropTypes.number, + validationErrorText: PropTypes.string, + // Filter + enableFilter: PropTypes.bool, + filteringStrategy: PropTypes.func, + // Select All + enableSelectAll: PropTypes.bool, + selectAllText: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), + // Ellipsis + enableEllipsis: PropTypes.bool +} + +MultiSelect.defaultProps = { + // Data + options: [], + selectedOptions: [], + onChange: _.noop, + placeholder: 'Please Select', + height: '35px', + disabled: false, + maxSize: 500, + validationErrorText: '', + // Filter + enableFilter: true, + filteringStrategy: startsWithStrategy, + // Select All + enableSelectAll: false, + selectAllText: 'Select All', + // Ellipsis + enableEllipsis: false +} + +export default MultiSelect diff --git a/ui/src/components/MultiSelect/OptionItem/OptionItem.scss b/ui/src/components/MultiSelect/OptionItem/OptionItem.scss new file mode 100644 index 000000000..8a1aabac4 --- /dev/null +++ b/ui/src/components/MultiSelect/OptionItem/OptionItem.scss @@ -0,0 +1,36 @@ + +@import "../../styles/colors"; + +$list-side-padding: 20px; +$hover-background-color: $default-blue; +$hover-color: $white; +$hover-outline-space: -1px; + +.option-item { + padding: 3px $list-side-padding; + margin: 0; + + .option-item__checkbox { + position: relative; + } + + &:hover { + background-color: $hover-background-color; + + .option-item__option { + color: $hover-color; + } + + .option-item__checkbox:after { + content: " "; + position: absolute; + display: block; + top: $hover-outline-space; + right: $hover-outline-space; + bottom: $hover-outline-space; + left: $hover-outline-space; + border-radius: 2px; + border: 1px solid $white; + } + } +} diff --git a/ui/src/components/MultiSelect/OptionItem/index.js b/ui/src/components/MultiSelect/OptionItem/index.js new file mode 100644 index 000000000..7d3f44071 --- /dev/null +++ b/ui/src/components/MultiSelect/OptionItem/index.js @@ -0,0 +1,33 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' + +import LabeledCheckbox from '../../LabeledCheckbox/LabeledCheckbox.export' + +import style from './OptionItem.scss' + +const OptionItem = ({ children, ...props }) => { + const [isHovering, setIsHovering] = useState(false) + + return ( + setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + className={style['option-item']} + checkboxClassName={style['option-item__checkbox']} + > + + {React.isValidElement(children) ? React.cloneElement(children, { isHover: isHovering }) : children} + + + ) +} + +OptionItem.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.arrayOf(PropTypes.node) + ]) +} + +export default OptionItem diff --git a/ui/src/components/MultiSelect/USAGE.md b/ui/src/components/MultiSelect/USAGE.md new file mode 100644 index 000000000..8b5575dd3 --- /dev/null +++ b/ui/src/components/MultiSelect/USAGE.md @@ -0,0 +1,69 @@ +# MultiSelect + +### Example + + + +### Usage + +```js +import { MultiSelect } from 'generic-ui-components' + +const options = [ + { key: 'key_1', value: 'value_1' }, + { key: 'key_2', value: 'value_2' }, + { key: 'key_3', value: 'value_3' } +]; + +const startsWithStrategy = ({ array = [], propName, value }) => { + const lowerCaseValue = value.toLowerCase(); + return array.filter(object => object[propName].toLowerCase().startsWith(lowerCaseValue)) +}; + +const onSelectedOptionsChange = (options) => { + console.log(options); // OUTPUT: [{ key: 'key_1', value: 'value_1' }] +}; + + onSelectedOptionsChange(options)} + placeholder={"Please select an option"} + height={'35px'} + disabled={false} + maxSize={50} + validationErrorText='' + enableFilter={true} + filteringStrategy={startsWithStrategy} + enableSelectAll={true} + selectAllText={'Check All'} + enableEllipsis={true} +/> + +``` + +Note:
+If a component is used as an option value. +i.e +```js +options=[...{key: 'key1', value: }] +``` +`` will receive a boolean `isHover` to indicate if hovering the row + +### Properties + +| propName | propType | defaultValue | isRequired | description | +| ------------------- | ---------------- | --------------- | ---------- | ----------------------------------------------------------------------------------------- | +| options | array of objects | [] | + | | +| selectedOptions | array of objects | [] | - | | +| onChange | function | - | + | | +| placeholder | string | 'Please Select' | - | | +| height | string | '35px' | - | | +| disabled | bool | false | - | | +| maxSize | number | 500 | - | Defines the number of visible options in a drop-down list | +| validationErrorText | string | '' | - | | +| enableFilter | bool | true | - | | +| filteringStrategy | function | StartWith | - | Returns the items begin with the characters of a given input | +| enableSelectAll | bool | false | - | | +| selectAllText | string | 'Select All' | - | | +| enableEllipsis | bool | false | - | Makes the overflowed content that is not displayed to be displayed in Ellipsis mode (...) | \ No newline at end of file diff --git a/ui/src/components/MultiSelect/style/MultiSelect.scss b/ui/src/components/MultiSelect/style/MultiSelect.scss new file mode 100644 index 000000000..1ab32f1fe --- /dev/null +++ b/ui/src/components/MultiSelect/style/MultiSelect.scss @@ -0,0 +1,58 @@ +@import "../../styles/colors"; + +$list-side-padding: 20px; + +.wrapper { + width: 100%; + position: relative; + line-height: 18px; + + &[data-disabled="true"] { + pointer-events: none; + } +} +.overflow-icon { + padding-top: 2px; + font-size: 17px; + padding-right: 8px; + color: $default-turquoise; +} + +.selection-input { + width: 53px; + font-size: 12px; + padding: 3px 5px 0px; + height: 27px; +} + +.input-item { + font-size: 12px; + height: 25px; + margin-right: 4px; + margin-bottom: calc((var(--multiple-select-height) - 27px) / 2); +} + +.list { + transform: translateY(1px); + z-index: 99999; + width: 100%; + min-height: 93px; + background-color: $white; + box-shadow: 0 7px 40px 0 rgba(0, 0, 0, 0.2); + border-radius: 3px; + position: absolute; + padding-bottom: 9px; + animation: fadein 0.1s; + @keyframes fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +} + +.ellipsis { + height: var(--multiple-select-height); +} diff --git a/ui/src/components/TitleInput/index.js b/ui/src/components/TitleInput/index.js index 6b2b17af4..6d23a274f 100644 --- a/ui/src/components/TitleInput/index.js +++ b/ui/src/components/TitleInput/index.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types' import classnames from 'classnames' import css from './TitleInput.scss' -const ParentWrap = ({wrap,style, children}) => { +const ParentWrap = ({wrap, style, children}) => { if (wrap) { return (
@@ -19,12 +19,18 @@ const ParentWrap = ({wrap,style, children}) => { } } -const TitleInput = ({title, style,width, disabled, className, children, prefix, suffix, alert, rightComponent, ...rest}) => { +const TitleInput = ({title, style,labelStyle, width, disabled, className, children, prefix, suffix, alert, rightComponent, ...rest}) => { const childrenExist = Boolean(children) return ( -
-
} + dataId={`tooltipKey_${data.key}`} + place='top' + offset={{top: 1}} + > +
+ +
+ + ); +} + +export default InfoToolTip diff --git a/ui/src/features/components/JobForm/constants.js b/ui/src/features/components/JobForm/constants.js index 1c165f68a..55aba652d 100644 --- a/ui/src/features/components/JobForm/constants.js +++ b/ui/src/features/components/JobForm/constants.js @@ -10,4 +10,5 @@ export const inputTypes = { SWITCHER: 'SWITCHER', TEXT_FIELD: 'TEXT_FIELD', RADIO: 'RADIO', + MULTI_SELECT: 'MULTI_SELECT', }; diff --git a/ui/src/features/components/JobForm/index.js b/ui/src/features/components/JobForm/index.js index b5ce7740b..bf7e55f97 100644 --- a/ui/src/features/components/JobForm/index.js +++ b/ui/src/features/components/JobForm/index.js @@ -2,9 +2,9 @@ import React, {Fragment} from 'react'; import style from './style.scss'; import {connect} from 'react-redux'; import {processingCreateJob, createJobSuccess, createJobFailure} from '../../redux/selectors/jobsSelector'; +import {webhooksForDropdown} from '../../redux/selectors/webhooksSelector'; import * as Actions from '../../redux/action'; import ErrorDialog from '../ErrorDialog'; -import TooltipWrapper from '../../../components/TooltipWrapper'; import RactangleAlignChildrenLeft from '../../../components/RectangleAlign/RectangleAlignChildrenLeft'; import {validate} from './validator'; import CronViewer from './cronViewer'; @@ -14,8 +14,6 @@ import TitleInput from '../../../components/TitleInput' import Input from '../../../components/Input' import FormWrapper from "../../../components/FormWrapper"; import ErrorWrapper from "../../../components/ErrorWrapper"; -import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; -import {faQuestionCircle} from '@fortawesome/free-solid-svg-icons' import TextArea from '../../../components/TextArea'; import MultiValueInput from '../../../components/MultiValueInput'; import UiSwitcher from '../../../components/UiSwitcher'; @@ -23,7 +21,8 @@ import {filter} from 'lodash'; import {createJobRequest} from '../../requestBuilder'; import RadioOptions from '../../../components/RadioOptions'; import {inputTypes, testTypes} from './constants'; - +import MultiSelect from '../../../components/MultiSelect/MultiSelect.export'; +import InfoToolTip from '../InfoToolTip'; const DESCRIPTION = 'Predator executes tests through jobs. Use this form to specify the parameters for the job you want to execute.'; @@ -64,6 +63,15 @@ class Form extends React.Component { info: 'Add notes about the test.', type: inputTypes.TEXT_FIELD }, + { + name: 'webhooks', + key: 'webhooks', + floatingLabelText: 'Webhooks', + info: 'Send test reports to Slack.', + element: 'Webhook', + type: inputTypes.MULTI_SELECT, + options: (props) => props.webhooks + }, { name: 'arrival_rate', key: 'arrival_rate', @@ -139,14 +147,6 @@ class Form extends React.Component { element: 'Email', type: inputTypes.INPUT_LIST }, - { - name: 'webhooks', - key: 'webhooks', - floatingLabelText: 'Webhooks', - info: 'Send test reports to Slack.', - element: 'Webhook', - type: inputTypes.INPUT_LIST - } ]; this.state = { @@ -194,6 +194,10 @@ class Form extends React.Component { this.props.clearErrorOnCreateJob(); } + componentDidMount() { + this.props.getWebhooks(); + } + onChangeProperty = (name, value) => { const newState = Object.assign({}, this.state, {[name]: value}); newState.errors = validate(newState); @@ -221,27 +225,10 @@ class Form extends React.Component { })); } - showInfo(item) { - if (!item || !item.info) { - return null; - } - return ( - {item.info} -
} - dataId={`tooltipKey_${item.key}`} - place='top' - offset={{top: 1}} - > -
- -
- - ); - } render() { + + const {closeDialog, processingAction, serverError, clearErrorOnCreateJob} = this.props; return ( @@ -274,12 +261,28 @@ class Form extends React.Component { } generateInput = (oneItem) => { + // const options = [ + // { key: 'key_1', value: 'value_1' }, + // { key: 'key_2', value: 'value_2' }, + // { key: 'key_3', value: 'value_3' } + // ]; + + const startsWithStrategy = ({array = [], propName, value}) => { + const lowerCaseValue = value.toLowerCase(); + return array.filter(object => object[propName].toLowerCase().startsWith(lowerCaseValue)) + }; + + const onSelectedOptionsChange = (options) => { + console.log(options); // OUTPUT: [{ key: 'key_1', value: 'value_1' }] + }; + + const {cron_expression} = this.state; switch (oneItem.type) { case inputTypes.SWITCHER: return ( + rightComponent={}> this.handleChangeForCheckBox(oneItem.name, value)} @@ -296,7 +299,7 @@ class Form extends React.Component { case inputTypes.INPUT_LIST: return ( + rightComponent={}> ({value, label: value}))} onAddItem={(evt) => this.handleInputListAdd(oneItem.name, evt)} @@ -310,7 +313,7 @@ class Form extends React.Component { case inputTypes.TEXT_FIELD: return ( + rightComponent={}>