From 0c06af306e37475c46b0ff0f321300f6b35e5512 Mon Sep 17 00:00:00 2001 From: Andrew Mavdryk Date: Sun, 19 Jun 2022 10:24:05 +0300 Subject: [PATCH] Impl [Models] Add `FormKeyValueTable` component (#20) --- package.json | 6 +- src/lib/components/FormInput/FormInput.js | 16 +- src/lib/components/FormInput/formInput.scss | 5 +- .../FormKeyValueTable/FormKeyValueTable.js | 280 ++++++++++++++++++ .../FormKeyValueTable/formKeyValueTable.scss | 117 ++++++++ src/lib/components/FormSelect/formSelect.scss | 4 +- src/lib/components/Wizard/Wizard.js | 16 +- src/lib/components/index.js | 1 + src/lib/scss/mixins.scss | 130 ++++---- src/lib/utils/form.util.js | 14 + ...alidationService.js => validation.util.js} | 0 11 files changed, 503 insertions(+), 86 deletions(-) create mode 100644 src/lib/components/FormKeyValueTable/FormKeyValueTable.js create mode 100644 src/lib/components/FormKeyValueTable/formKeyValueTable.scss create mode 100644 src/lib/utils/form.util.js rename src/lib/utils/{validationService.js => validation.util.js} (100%) diff --git a/package.json b/package.json index ee6485f6..c5917b59 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "react-modal-promise": "*", "react-transition-group": "*", "final-form": "*", - "react-final-form": "*" + "final-form-arrays": "*", + "react-final-form": "*", + "react-final-form-arrays": "*" }, "devDependencies": { "@babel/cli": "^7.17.6", @@ -65,7 +67,9 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "final-form": "^4.20.7", + "final-form-arrays": "^3.0.2", "react-final-form": "^6.5.9", + "react-final-form-arrays": "^3.1.3", "react-modal-promise": "^1.0.2", "react-scripts": "5.0.0", "react-transition-group": "^4.4.2", diff --git a/src/lib/components/FormInput/FormInput.js b/src/lib/components/FormInput/FormInput.js index f42aecc1..748c51a0 100644 --- a/src/lib/components/FormInput/FormInput.js +++ b/src/lib/components/FormInput/FormInput.js @@ -10,7 +10,7 @@ import Tip from '../Tip/Tip' import Tooltip from '../Tooltip/Tooltip' import ValidationTemplate from '../../elements/ValidationTemplate/ValidationTemplate' -import { checkPatternsValidity } from '../../utils/validationService' +import { checkPatternsValidity } from '../../utils/validation.util' import { useDetectOutsideClick } from '../../hooks/useDetectOutsideClick' import { INPUT_LINK, INPUT_VALIDATION_RULES } from '../../types' @@ -84,6 +84,12 @@ const FormInput = React.forwardRef( ) }, [meta.invalid, meta.modified, meta.submitFailed, meta.touched, meta.validating]) + useEffect(() => { + if (meta.valid && showValidationRules) { + setShowValidationRules(false) + } + }, [meta.valid, showValidationRules]) + useEffect(() => { if (showValidationRules) { window.addEventListener('scroll', handleScroll, true) @@ -160,20 +166,16 @@ const FormInput = React.forwardRef( const valueToValidate = value ?? '' let validationError = null - if (!isEmpty(validationRules) && valueToValidate !== typedValue) { + if (!isEmpty(validationRules)) { const [newRules, isValidField] = checkPatternsValidity(rules, valueToValidate) const invalidRules = newRules.filter((rule) => !rule.isValid) if (!isValidField) { validationError = invalidRules.map((rule) => ({ name: rule.name, label: rule.label })) } - - if ((isValidField && showValidationRules) || (required && valueToValidate === '')) { - setShowValidationRules(false) - } } - if (!validationError) { + if (isEmpty(validationError)) { if (pattern && !validationPattern.test(valueToValidate)) { validationError = { name: 'pattern', label: invalidText } } else if (valueToValidate.startsWith(' ')) { diff --git a/src/lib/components/FormInput/formInput.scss b/src/lib/components/FormInput/formInput.scss index 2f4ff5dc..adaaee7e 100644 --- a/src/lib/components/FormInput/formInput.scss +++ b/src/lib/components/FormInput/formInput.scss @@ -3,9 +3,9 @@ @import '../../scss/shadows'; @import '../../scss/mixins'; -@include formField; - .form-field { + @include formField; + &__label { &-icon { display: inline-flex; @@ -34,6 +34,7 @@ input { border: 0; color: inherit; + background-color: transparent; padding: 0; width: 100%; diff --git a/src/lib/components/FormKeyValueTable/FormKeyValueTable.js b/src/lib/components/FormKeyValueTable/FormKeyValueTable.js new file mode 100644 index 00000000..d3773ac0 --- /dev/null +++ b/src/lib/components/FormKeyValueTable/FormKeyValueTable.js @@ -0,0 +1,280 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { FieldArray } from 'react-final-form-arrays' + +import Tooltip from '../Tooltip/Tooltip' +import TextTooltipTemplate from '../TooltipTemplate/TextTooltipTemplate' +import RoundedIcon from '../RoundedIcon/RoundedIcon' +import FormInput from '../FormInput/FormInput' +import FormSelect from '../FormSelect/FormSelect' + +import { ReactComponent as Close } from '../../images/close.svg' +import { ReactComponent as Edit } from '../../images/edit.svg' +import { ReactComponent as Plus } from '../../images/plus.svg' +import { ReactComponent as Delete } from '../../images/delete.svg' +import { ReactComponent as Checkmark } from '../../images/checkmark2.svg' + +import './formKeyValueTable.scss' + +const FormKeyValueTable = ({ + addNewItemLabel, + className, + disabled, + formState, + isKeyRequired, + isValueRequired, + keyHeader, + keyLabel, + keyOptions, + name, + valueHeader, + valueLabel +}) => { + const [isEditMode, setEditMode] = useState(false) + const [selectedItem, setSelectedItem] = useState(null) + const tableClassNames = classnames('form-key-value-table', className) + const addBtnClassNames = classnames('add-new-item-btn', disabled && 'disabled') + + const exitEditMode = () => { + setSelectedItem(null) + setEditMode(false) + } + + const enterEditMode = (event, fields, index) => { + if (!disabled) { + applyOrDiscardOrDelete(event, fields) + exitEditMode() + + const editItem = fields.value[index] + setSelectedItem({ ...editItem, index }) + setEditMode(true) + } + } + + const applyChanges = (event, fields, index) => { + if (!formState?.errors?.[name]) { + exitEditMode() + } else { + formState.form.mutators.setFieldState(`${name}[${index}].key`, { modified: true }) + formState.form.mutators.setFieldState(`${name}[${index}].value`, { modified: true }) + } + } + + const discardChanges = (event, fields, index) => { + exitEditMode() + fields.update(index, { key: selectedItem.key, value: selectedItem.value }) + event && event.stopPropagation() + } + + const addNewRow = (event, fields) => { + if (!disabled) { + applyOrDiscardOrDelete(event, fields) + + formState.form.mutators.push(name, { key: '', value: '' }) + setSelectedItem({ + key: '', + value: '', + isNew: true, + index: formState.values[name]?.length ?? 0 + }) + setEditMode(true) + } + } + + const deleteRow = (event, fields, index) => { + if (isEditMode && index !== selectedItem.index) { + applyOrDiscardOrDelete(event, fields) + } + + exitEditMode() + fields.remove(index) + event && event.stopPropagation() + } + + const applyOrDiscardOrDelete = (event, fields) => { + if (isEditMode) { + if (!formState?.errors?.[name]) { + applyChanges(event, fields, selectedItem.index) + } else { + discardOrDelete(event, fields, selectedItem.index) + } + } + } + + const discardOrDelete = (event, fields, index) => { + if (selectedItem?.isNew || !isEditMode) { + deleteRow(event, fields, index) + } else { + discardChanges(event, fields, index) + } + } + + const uniquenessValidator = (fields, newValue) => { + return !fields.value.some(({ key }, index) => { + return newValue.trim() === key && index !== selectedItem.index + }) + } + + return ( +
+
+
+
{keyHeader}
+
{valueHeader}
+
+
+
+
+ + {({ fields }) => ( + <> + {fields.map((contentItem, index) => { + return isEditMode && index === selectedItem.index && !disabled ? ( +
+
+ {keyOptions ? ( + + ) : ( + uniquenessValidator(fields, newValue) + } + ]} + /> + )} +
+
+ +
+
+ applyChanges(event, fields, index)} + tooltipText="Apply" + > + + + discardOrDelete(event, fields, index)} + tooltipText={selectedItem.isNew ? 'Delete' : 'Discard changes'} + > + {selectedItem.isNew ? : } + +
+
+ ) : ( +
enterEditMode(event, fields, index)} + > +
+
+ }> + {fields.value[index].key} + +
+
+ } + > + {fields.value[index].value} + +
+
+
+ { + event.preventDefault() + }} + tooltipText="Edit" + > + + + + { + deleteRow(event, fields, index) + }} + tooltipText="Delete" + > + + +
+
+ ) + })} + + {!selectedItem?.isNew && ( +
+ +
+ )} + + )} +
+
+
+ ) +} + +FormKeyValueTable.defaultProps = { + addNewItemLabel: 'Add new item', + className: '', + disabled: false, + isKeyRequired: true, + isValueRequired: true, + keyHeader: 'Key', + keyLabel: 'Key', + keyOptions: null, + valueHeader: 'Value', + valueLabel: 'Value' +} + +FormKeyValueTable.propTypes = { + addNewItemLabel: PropTypes.string, + className: PropTypes.string, + disabled: PropTypes.bool, + formState: PropTypes.shape({}).isRequired, + isKeyRequired: PropTypes.bool, + isValueRequired: PropTypes.bool, + keyHeader: PropTypes.string, + keyLabel: PropTypes.string, + keyOptions: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + id: PropTypes.string.isRequired + }) + ), + name: PropTypes.string.isRequired, + valueHeader: PropTypes.string, + valueLabel: PropTypes.string +} + +export default FormKeyValueTable diff --git a/src/lib/components/FormKeyValueTable/formKeyValueTable.scss b/src/lib/components/FormKeyValueTable/formKeyValueTable.scss new file mode 100644 index 00000000..d1cfc578 --- /dev/null +++ b/src/lib/components/FormKeyValueTable/formKeyValueTable.scss @@ -0,0 +1,117 @@ +@import '../../scss/borders'; +@import '../../scss/colors'; +$actionsBlockWidth: 72px; + +.form-key-value-table { + max-height: 280px; + overflow: hidden; + overflow-y: auto; + background-color: $white; + + &__btn { + visibility: hidden; + } + + &__body { + background-color: inherit; + } + + .table-row { + display: flex; + align-items: center; + min-height: 56px; + border-bottom: $primaryBorder; + + &:not(.no-hover):hover { + background-color: $alabaster; + + .key-value-table__btn { + visibility: visible; + } + } + + &__last { + position: sticky; + bottom: 0; + z-index: 3; + background-color: inherit; + border: 0; + } + } + + .table-row__header { + position: sticky; + top: 0; + z-index: 3; + font-weight: bold; + font-size: 18px; + background-color: inherit; + border-top: $primaryBorder; + } + + .table-cell { + display: flex; + align-items: center; + padding: 8px 5px 8px 10px; + color: $primary; + } + + .table-cell__inputs-wrapper { + display: flex; + width: calc(100% - #{$actionsBlockWidth}); + } + + .table-cell__key { + width: 50%; + } + + .table-cell__value { + width: 50%; + } + + .table-cell__actions { + justify-content: flex-end; + min-width: $actionsBlockWidth; + padding: 0; + + & > * { + margin: 0 5px 0 0; + } + } + + .add-new-item-btn { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 44px; + padding: 10px; + color: $cornflowerBlue; + font-size: 15px; + line-height: 18px; + + svg { + width: 20px; + height: 20px; + + rect { + fill: $cornflowerBlue; + } + } + } + + .input-wrapper { + width: 100%; + + .input { + width: 100%; + + &_edit { + border: $primaryBorder; + } + + &_invalid { + border: $errorBorder; + } + } + } +} diff --git a/src/lib/components/FormSelect/formSelect.scss b/src/lib/components/FormSelect/formSelect.scss index 9290e691..ca282315 100644 --- a/src/lib/components/FormSelect/formSelect.scss +++ b/src/lib/components/FormSelect/formSelect.scss @@ -2,9 +2,9 @@ @import '../../scss/colors'; @import '../../scss/shadows'; -@include formField; - .form-field { + @include formField; + &__wrapper { &-active { background: $alabaster; diff --git a/src/lib/components/Wizard/Wizard.js b/src/lib/components/Wizard/Wizard.js index f23349ff..f1076631 100644 --- a/src/lib/components/Wizard/Wizard.js +++ b/src/lib/components/Wizard/Wizard.js @@ -17,7 +17,7 @@ const Wizard = ({ children, className, confirmClose, - FormState, + formState, isWizardOpen, onWizardResolve, onWizardSubmit, @@ -57,7 +57,7 @@ const Wizard = ({ } const handleOnClose = () => { - if (confirmClose && FormState && FormState.dirty) { + if (confirmClose && formState && formState.dirty) { openPopUp(ConfirmDialog, { cancelButton: { label: 'Cancel', @@ -77,10 +77,10 @@ const Wizard = ({ } const handleSubmit = () => { - FormState.handleSubmit() - if (FormState.valid) { + formState.handleSubmit() + if (formState.valid) { if (isLastStep) { - onWizardSubmit(FormState.values) + onWizardSubmit(formState.values) } else { goToNextStep() } @@ -96,7 +96,7 @@ const Wizard = ({ />,