Skip to content

Commit 0c06af3

Browse files
authored
Impl [Models] Add FormKeyValueTable component (#20)
1 parent 582bdaf commit 0c06af3

File tree

11 files changed

+503
-86
lines changed

11 files changed

+503
-86
lines changed

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
"react-modal-promise": "*",
2828
"react-transition-group": "*",
2929
"final-form": "*",
30-
"react-final-form": "*"
30+
"final-form-arrays": "*",
31+
"react-final-form": "*",
32+
"react-final-form-arrays": "*"
3133
},
3234
"devDependencies": {
3335
"@babel/cli": "^7.17.6",
@@ -65,7 +67,9 @@
6567
"react": "^17.0.2",
6668
"react-dom": "^17.0.2",
6769
"final-form": "^4.20.7",
70+
"final-form-arrays": "^3.0.2",
6871
"react-final-form": "^6.5.9",
72+
"react-final-form-arrays": "^3.1.3",
6973
"react-modal-promise": "^1.0.2",
7074
"react-scripts": "5.0.0",
7175
"react-transition-group": "^4.4.2",

src/lib/components/FormInput/FormInput.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import Tip from '../Tip/Tip'
1010
import Tooltip from '../Tooltip/Tooltip'
1111
import ValidationTemplate from '../../elements/ValidationTemplate/ValidationTemplate'
1212

13-
import { checkPatternsValidity } from '../../utils/validationService'
13+
import { checkPatternsValidity } from '../../utils/validation.util'
1414
import { useDetectOutsideClick } from '../../hooks/useDetectOutsideClick'
1515

1616
import { INPUT_LINK, INPUT_VALIDATION_RULES } from '../../types'
@@ -84,6 +84,12 @@ const FormInput = React.forwardRef(
8484
)
8585
}, [meta.invalid, meta.modified, meta.submitFailed, meta.touched, meta.validating])
8686

87+
useEffect(() => {
88+
if (meta.valid && showValidationRules) {
89+
setShowValidationRules(false)
90+
}
91+
}, [meta.valid, showValidationRules])
92+
8793
useEffect(() => {
8894
if (showValidationRules) {
8995
window.addEventListener('scroll', handleScroll, true)
@@ -160,20 +166,16 @@ const FormInput = React.forwardRef(
160166
const valueToValidate = value ?? ''
161167
let validationError = null
162168

163-
if (!isEmpty(validationRules) && valueToValidate !== typedValue) {
169+
if (!isEmpty(validationRules)) {
164170
const [newRules, isValidField] = checkPatternsValidity(rules, valueToValidate)
165171
const invalidRules = newRules.filter((rule) => !rule.isValid)
166172

167173
if (!isValidField) {
168174
validationError = invalidRules.map((rule) => ({ name: rule.name, label: rule.label }))
169175
}
170-
171-
if ((isValidField && showValidationRules) || (required && valueToValidate === '')) {
172-
setShowValidationRules(false)
173-
}
174176
}
175177

176-
if (!validationError) {
178+
if (isEmpty(validationError)) {
177179
if (pattern && !validationPattern.test(valueToValidate)) {
178180
validationError = { name: 'pattern', label: invalidText }
179181
} else if (valueToValidate.startsWith(' ')) {

src/lib/components/FormInput/formInput.scss

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
@import '../../scss/shadows';
44
@import '../../scss/mixins';
55

6-
@include formField;
7-
86
.form-field {
7+
@include formField;
8+
99
&__label {
1010
&-icon {
1111
display: inline-flex;
@@ -34,6 +34,7 @@
3434
input {
3535
border: 0;
3636
color: inherit;
37+
background-color: transparent;
3738
padding: 0;
3839
width: 100%;
3940

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import React, { useState } from 'react'
2+
import PropTypes from 'prop-types'
3+
import classnames from 'classnames'
4+
import { FieldArray } from 'react-final-form-arrays'
5+
6+
import Tooltip from '../Tooltip/Tooltip'
7+
import TextTooltipTemplate from '../TooltipTemplate/TextTooltipTemplate'
8+
import RoundedIcon from '../RoundedIcon/RoundedIcon'
9+
import FormInput from '../FormInput/FormInput'
10+
import FormSelect from '../FormSelect/FormSelect'
11+
12+
import { ReactComponent as Close } from '../../images/close.svg'
13+
import { ReactComponent as Edit } from '../../images/edit.svg'
14+
import { ReactComponent as Plus } from '../../images/plus.svg'
15+
import { ReactComponent as Delete } from '../../images/delete.svg'
16+
import { ReactComponent as Checkmark } from '../../images/checkmark2.svg'
17+
18+
import './formKeyValueTable.scss'
19+
20+
const FormKeyValueTable = ({
21+
addNewItemLabel,
22+
className,
23+
disabled,
24+
formState,
25+
isKeyRequired,
26+
isValueRequired,
27+
keyHeader,
28+
keyLabel,
29+
keyOptions,
30+
name,
31+
valueHeader,
32+
valueLabel
33+
}) => {
34+
const [isEditMode, setEditMode] = useState(false)
35+
const [selectedItem, setSelectedItem] = useState(null)
36+
const tableClassNames = classnames('form-key-value-table', className)
37+
const addBtnClassNames = classnames('add-new-item-btn', disabled && 'disabled')
38+
39+
const exitEditMode = () => {
40+
setSelectedItem(null)
41+
setEditMode(false)
42+
}
43+
44+
const enterEditMode = (event, fields, index) => {
45+
if (!disabled) {
46+
applyOrDiscardOrDelete(event, fields)
47+
exitEditMode()
48+
49+
const editItem = fields.value[index]
50+
setSelectedItem({ ...editItem, index })
51+
setEditMode(true)
52+
}
53+
}
54+
55+
const applyChanges = (event, fields, index) => {
56+
if (!formState?.errors?.[name]) {
57+
exitEditMode()
58+
} else {
59+
formState.form.mutators.setFieldState(`${name}[${index}].key`, { modified: true })
60+
formState.form.mutators.setFieldState(`${name}[${index}].value`, { modified: true })
61+
}
62+
}
63+
64+
const discardChanges = (event, fields, index) => {
65+
exitEditMode()
66+
fields.update(index, { key: selectedItem.key, value: selectedItem.value })
67+
event && event.stopPropagation()
68+
}
69+
70+
const addNewRow = (event, fields) => {
71+
if (!disabled) {
72+
applyOrDiscardOrDelete(event, fields)
73+
74+
formState.form.mutators.push(name, { key: '', value: '' })
75+
setSelectedItem({
76+
key: '',
77+
value: '',
78+
isNew: true,
79+
index: formState.values[name]?.length ?? 0
80+
})
81+
setEditMode(true)
82+
}
83+
}
84+
85+
const deleteRow = (event, fields, index) => {
86+
if (isEditMode && index !== selectedItem.index) {
87+
applyOrDiscardOrDelete(event, fields)
88+
}
89+
90+
exitEditMode()
91+
fields.remove(index)
92+
event && event.stopPropagation()
93+
}
94+
95+
const applyOrDiscardOrDelete = (event, fields) => {
96+
if (isEditMode) {
97+
if (!formState?.errors?.[name]) {
98+
applyChanges(event, fields, selectedItem.index)
99+
} else {
100+
discardOrDelete(event, fields, selectedItem.index)
101+
}
102+
}
103+
}
104+
105+
const discardOrDelete = (event, fields, index) => {
106+
if (selectedItem?.isNew || !isEditMode) {
107+
deleteRow(event, fields, index)
108+
} else {
109+
discardChanges(event, fields, index)
110+
}
111+
}
112+
113+
const uniquenessValidator = (fields, newValue) => {
114+
return !fields.value.some(({ key }, index) => {
115+
return newValue.trim() === key && index !== selectedItem.index
116+
})
117+
}
118+
119+
return (
120+
<div className={tableClassNames}>
121+
<div className="table-row table-row__header no-hover">
122+
<div className="table-cell__inputs-wrapper">
123+
<div className="table-cell table-cell__key">{keyHeader}</div>
124+
<div className="table-cell table-cell__value">{valueHeader}</div>
125+
</div>
126+
<div className="table-cell table-cell__actions" />
127+
</div>
128+
<div className="key-value-table__body">
129+
<FieldArray name={name}>
130+
{({ fields }) => (
131+
<>
132+
{fields.map((contentItem, index) => {
133+
return isEditMode && index === selectedItem.index && !disabled ? (
134+
<div className="table-row table-row_edit" key={index}>
135+
<div className="table-cell table-cell__key">
136+
{keyOptions ? (
137+
<FormSelect name={`${contentItem}.key`} density="dense" options={keyOptions} />
138+
) : (
139+
<FormInput
140+
className="input_edit"
141+
placeholder={keyLabel}
142+
density="dense"
143+
name={`${contentItem}.key`}
144+
required={isKeyRequired}
145+
validationRules={[
146+
{
147+
name: 'uniqueness',
148+
label: 'Name should be unique',
149+
pattern: (newValue) => uniquenessValidator(fields, newValue)
150+
}
151+
]}
152+
/>
153+
)}
154+
</div>
155+
<div className="table-cell table-cell__value">
156+
<FormInput
157+
className="input_edit"
158+
placeholder={valueLabel}
159+
density="dense"
160+
name={`${contentItem}.value`}
161+
required={isValueRequired}
162+
/>
163+
</div>
164+
<div className="table-cell table-cell__actions">
165+
<RoundedIcon
166+
className="key-value-table__btn"
167+
onClick={event => applyChanges(event, fields, index)}
168+
tooltipText="Apply"
169+
>
170+
<Checkmark />
171+
</RoundedIcon>
172+
<RoundedIcon
173+
className="key-value-table__btn"
174+
onClick={event => discardOrDelete(event, fields, index)}
175+
tooltipText={selectedItem.isNew ? 'Delete' : 'Discard changes'}
176+
>
177+
{selectedItem.isNew ? <Delete /> : <Close />}
178+
</RoundedIcon>
179+
</div>
180+
</div>
181+
) : (
182+
<div
183+
className="table-row"
184+
key={index}
185+
onClick={event => enterEditMode(event, fields, index)}
186+
>
187+
<div className="table-cell__inputs-wrapper">
188+
<div className="table-cell table-cell__key">
189+
<Tooltip template={<TextTooltipTemplate text={fields.value[index].key} />}>
190+
{fields.value[index].key}
191+
</Tooltip>
192+
</div>
193+
<div className="table-cell table-cell__value">
194+
<Tooltip
195+
template={<TextTooltipTemplate text={fields.value[index].value} />}
196+
>
197+
{fields.value[index].value}
198+
</Tooltip>
199+
</div>
200+
</div>
201+
<div className="table-cell table-cell__actions">
202+
<RoundedIcon
203+
className="key-value-table__btn"
204+
onClick={event => {
205+
event.preventDefault()
206+
}}
207+
tooltipText="Edit"
208+
>
209+
<Edit />
210+
</RoundedIcon>
211+
212+
<RoundedIcon
213+
className="key-value-table__btn"
214+
onClick={event => {
215+
deleteRow(event, fields, index)
216+
}}
217+
tooltipText="Delete"
218+
>
219+
<Delete />
220+
</RoundedIcon>
221+
</div>
222+
</div>
223+
)
224+
})}
225+
226+
{!selectedItem?.isNew && (
227+
<div className="table-row table-row__last no-hover">
228+
<button
229+
className={addBtnClassNames}
230+
onClick={event => {
231+
addNewRow(event, fields)
232+
}}
233+
>
234+
<Plus />
235+
{addNewItemLabel}
236+
</button>
237+
</div>
238+
)}
239+
</>
240+
)}
241+
</FieldArray>
242+
</div>
243+
</div>
244+
)
245+
}
246+
247+
FormKeyValueTable.defaultProps = {
248+
addNewItemLabel: 'Add new item',
249+
className: '',
250+
disabled: false,
251+
isKeyRequired: true,
252+
isValueRequired: true,
253+
keyHeader: 'Key',
254+
keyLabel: 'Key',
255+
keyOptions: null,
256+
valueHeader: 'Value',
257+
valueLabel: 'Value'
258+
}
259+
260+
FormKeyValueTable.propTypes = {
261+
addNewItemLabel: PropTypes.string,
262+
className: PropTypes.string,
263+
disabled: PropTypes.bool,
264+
formState: PropTypes.shape({}).isRequired,
265+
isKeyRequired: PropTypes.bool,
266+
isValueRequired: PropTypes.bool,
267+
keyHeader: PropTypes.string,
268+
keyLabel: PropTypes.string,
269+
keyOptions: PropTypes.arrayOf(
270+
PropTypes.shape({
271+
label: PropTypes.string.isRequired,
272+
id: PropTypes.string.isRequired
273+
})
274+
),
275+
name: PropTypes.string.isRequired,
276+
valueHeader: PropTypes.string,
277+
valueLabel: PropTypes.string
278+
}
279+
280+
export default FormKeyValueTable

0 commit comments

Comments
 (0)