Skip to content

Commit

Permalink
Impl [Models] Add FormKeyValueTable component (#20)
Browse files Browse the repository at this point in the history
  • Loading branch information
mavdryk authored Jun 19, 2022
1 parent 582bdaf commit 0c06af3
Show file tree
Hide file tree
Showing 11 changed files with 503 additions and 86 deletions.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 9 additions & 7 deletions src/lib/components/FormInput/FormInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(' ')) {
Expand Down
5 changes: 3 additions & 2 deletions src/lib/components/FormInput/formInput.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
@import '../../scss/shadows';
@import '../../scss/mixins';

@include formField;

.form-field {
@include formField;

&__label {
&-icon {
display: inline-flex;
Expand Down Expand Up @@ -34,6 +34,7 @@
input {
border: 0;
color: inherit;
background-color: transparent;
padding: 0;
width: 100%;

Expand Down
280 changes: 280 additions & 0 deletions src/lib/components/FormKeyValueTable/FormKeyValueTable.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className={tableClassNames}>
<div className="table-row table-row__header no-hover">
<div className="table-cell__inputs-wrapper">
<div className="table-cell table-cell__key">{keyHeader}</div>
<div className="table-cell table-cell__value">{valueHeader}</div>
</div>
<div className="table-cell table-cell__actions" />
</div>
<div className="key-value-table__body">
<FieldArray name={name}>
{({ fields }) => (
<>
{fields.map((contentItem, index) => {
return isEditMode && index === selectedItem.index && !disabled ? (
<div className="table-row table-row_edit" key={index}>
<div className="table-cell table-cell__key">
{keyOptions ? (
<FormSelect name={`${contentItem}.key`} density="dense" options={keyOptions} />
) : (
<FormInput
className="input_edit"
placeholder={keyLabel}
density="dense"
name={`${contentItem}.key`}
required={isKeyRequired}
validationRules={[
{
name: 'uniqueness',
label: 'Name should be unique',
pattern: (newValue) => uniquenessValidator(fields, newValue)
}
]}
/>
)}
</div>
<div className="table-cell table-cell__value">
<FormInput
className="input_edit"
placeholder={valueLabel}
density="dense"
name={`${contentItem}.value`}
required={isValueRequired}
/>
</div>
<div className="table-cell table-cell__actions">
<RoundedIcon
className="key-value-table__btn"
onClick={event => applyChanges(event, fields, index)}
tooltipText="Apply"
>
<Checkmark />
</RoundedIcon>
<RoundedIcon
className="key-value-table__btn"
onClick={event => discardOrDelete(event, fields, index)}
tooltipText={selectedItem.isNew ? 'Delete' : 'Discard changes'}
>
{selectedItem.isNew ? <Delete /> : <Close />}
</RoundedIcon>
</div>
</div>
) : (
<div
className="table-row"
key={index}
onClick={event => enterEditMode(event, fields, index)}
>
<div className="table-cell__inputs-wrapper">
<div className="table-cell table-cell__key">
<Tooltip template={<TextTooltipTemplate text={fields.value[index].key} />}>
{fields.value[index].key}
</Tooltip>
</div>
<div className="table-cell table-cell__value">
<Tooltip
template={<TextTooltipTemplate text={fields.value[index].value} />}
>
{fields.value[index].value}
</Tooltip>
</div>
</div>
<div className="table-cell table-cell__actions">
<RoundedIcon
className="key-value-table__btn"
onClick={event => {
event.preventDefault()
}}
tooltipText="Edit"
>
<Edit />
</RoundedIcon>

<RoundedIcon
className="key-value-table__btn"
onClick={event => {
deleteRow(event, fields, index)
}}
tooltipText="Delete"
>
<Delete />
</RoundedIcon>
</div>
</div>
)
})}

{!selectedItem?.isNew && (
<div className="table-row table-row__last no-hover">
<button
className={addBtnClassNames}
onClick={event => {
addNewRow(event, fields)
}}
>
<Plus />
{addNewItemLabel}
</button>
</div>
)}
</>
)}
</FieldArray>
</div>
</div>
)
}

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
Loading

0 comments on commit 0c06af3

Please sign in to comment.