From c0a47271147486fed147756297ceadcadfce209c Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari <chiptus@users.noreply.github.com> Date: Mon, 22 Nov 2021 18:13:40 +0200 Subject: [PATCH] feat(app): introduce input list component [EE-2003] (#6123) --- app/assets/css/index.js | 6 +- app/portainer/components/Button/AddButton.tsx | 4 +- .../form-components/Input/BaseInput.tsx | 2 +- .../form-components/Input/Select.tsx | 4 +- .../form-components/Input/TextInput.tsx | 3 +- .../InputList/InputList.module.css | 30 +++ .../InputList/InputList.stories.tsx | 90 +++++++++ .../form-components/InputList/InputList.tsx | 183 ++++++++++++++++++ .../form-components/InputList/index.ts | 1 + .../form-components/InputList/utils.test.ts | 23 +++ .../form-components/InputList/utils.ts | 37 ++++ yarn.lock | 2 +- 12 files changed, 376 insertions(+), 9 deletions(-) create mode 100644 app/portainer/components/form-components/InputList/InputList.module.css create mode 100644 app/portainer/components/form-components/InputList/InputList.stories.tsx create mode 100644 app/portainer/components/form-components/InputList/InputList.tsx create mode 100644 app/portainer/components/form-components/InputList/index.ts create mode 100644 app/portainer/components/form-components/InputList/utils.test.ts create mode 100644 app/portainer/components/form-components/InputList/utils.ts diff --git a/app/assets/css/index.js b/app/assets/css/index.js index e4a01560b..c48ded8c2 100644 --- a/app/assets/css/index.js +++ b/app/assets/css/index.js @@ -1,6 +1,3 @@ -import './rdash.css'; -import './app.css'; - import 'ui-select/dist/select.css'; import 'bootstrap/dist/css/bootstrap.css'; import '@fortawesome/fontawesome-free/css/brands.css'; @@ -17,5 +14,8 @@ import 'angular-moment-picker/dist/angular-moment-picker.min.css'; import 'angular-multiselect/isteven-multi-select.css'; import 'spinkit/spinkit.min.css'; +import './rdash.css'; +import './app.css'; + import './theme.css'; import './vendor-override.css'; diff --git a/app/portainer/components/Button/AddButton.tsx b/app/portainer/components/Button/AddButton.tsx index e95718ddc..2f97159a6 100644 --- a/app/portainer/components/Button/AddButton.tsx +++ b/app/portainer/components/Button/AddButton.tsx @@ -3,14 +3,16 @@ import clsx from 'clsx'; import styles from './AddButton.module.css'; export interface Props { + className?: string; label: string; onClick: () => void; } -export function AddButton({ label, onClick }: Props) { +export function AddButton({ label, onClick, className }: Props) { return ( <button className={clsx( + className, 'label', 'label-default', 'interactive', diff --git a/app/portainer/components/form-components/Input/BaseInput.tsx b/app/portainer/components/form-components/Input/BaseInput.tsx index 20d342a41..739f394b1 100644 --- a/app/portainer/components/form-components/Input/BaseInput.tsx +++ b/app/portainer/components/form-components/Input/BaseInput.tsx @@ -33,7 +33,7 @@ export function BaseInput({ readOnly={readonly} required={required} type={type} - className={clsx(className, 'form-control')} + className={clsx('form-control', className)} onChange={(e) => onChange(e.target.value)} rows={rows} /> diff --git a/app/portainer/components/form-components/Input/Select.tsx b/app/portainer/components/form-components/Input/Select.tsx index a4794f03c..d6776bd79 100644 --- a/app/portainer/components/form-components/Input/Select.tsx +++ b/app/portainer/components/form-components/Input/Select.tsx @@ -31,7 +31,9 @@ export function Select<T extends number | string>({ onChange={handleChange} > {options.map((item) => ( - <option value={item.value}>{item.label}</option> + <option value={item.value} key={item.value}> + {item.label} + </option> ))} </select> ); diff --git a/app/portainer/components/form-components/Input/TextInput.tsx b/app/portainer/components/form-components/Input/TextInput.tsx index a540abeb6..69e0301e0 100644 --- a/app/portainer/components/form-components/Input/TextInput.tsx +++ b/app/portainer/components/form-components/Input/TextInput.tsx @@ -1,4 +1,3 @@ -import clsx from 'clsx'; import { HTMLInputTypeAttribute } from 'react'; import { BaseInput } from './BaseInput'; @@ -23,7 +22,7 @@ export function TextInput({ <BaseInput id={id} type={type} - className={clsx(className, 'form-control')} + className={className} value={value} onChange={onChange} disabled={disabled} diff --git a/app/portainer/components/form-components/InputList/InputList.module.css b/app/portainer/components/form-components/InputList/InputList.module.css new file mode 100644 index 000000000..7be4f1390 --- /dev/null +++ b/app/portainer/components/form-components/InputList/InputList.module.css @@ -0,0 +1,30 @@ +.items { + margin-top: 10px; +} + +.items > * + * { + margin-top: 2px; +} + +.label { + text-align: left; + font-size: 0.9em; + padding-top: 7px; + margin-bottom: 0; + display: inline-block; + max-width: 100%; + font-weight: 700; +} + +.item-line { + display: flex; +} + +.item-actions { + display: flex; + margin-left: 2px; +} + +.default-item { + width: 100% !important; +} diff --git a/app/portainer/components/form-components/InputList/InputList.stories.tsx b/app/portainer/components/form-components/InputList/InputList.stories.tsx new file mode 100644 index 000000000..4b5f1f8d4 --- /dev/null +++ b/app/portainer/components/form-components/InputList/InputList.stories.tsx @@ -0,0 +1,90 @@ +import { Meta } from '@storybook/react'; +import { useState } from 'react'; + +import { NumberInput, Select } from '../Input'; + +import { DefaultType, InputList } from './InputList'; + +const meta: Meta = { + title: 'InputList', + component: InputList, +}; + +export default meta; + +export function Defaults() { + const [values, setValues] = useState<DefaultType[]>([{ value: '' }]); + + return ( + <InputList + label="default example" + value={values} + onChange={(value) => setValues(value)} + /> + ); +} + +interface ListWithSelectItem { + value: number; + select: string; + id: number; +} + +interface ListWithInputAndSelectArgs { + label: string; + movable: boolean; + tooltip: string; +} +export function ListWithInputAndSelect({ + label, + movable, + tooltip, +}: ListWithInputAndSelectArgs) { + const [values, setValues] = useState<ListWithSelectItem[]>([ + { value: 0, select: '', id: 0 }, + ]); + + return ( + <InputList<ListWithSelectItem> + label={label} + onChange={setValues} + value={values} + item={SelectAndInputItem} + itemKeyGetter={(item) => item.id} + movable={movable} + itemBuilder={() => ({ value: 0, select: '', id: values.length })} + tooltip={tooltip} + /> + ); +} + +ListWithInputAndSelect.args = { + label: 'List with select and input', + movable: false, + tooltip: '', +}; + +function SelectAndInputItem({ + item, + onChange, +}: { + item: ListWithSelectItem; + onChange: (value: ListWithSelectItem) => void; +}) { + return ( + <div> + <NumberInput + value={item.value} + onChange={(value: number) => onChange({ ...item, value })} + /> + <Select + onChange={(select: string) => onChange({ ...item, select })} + options={[ + { label: 'option1', value: 'option1' }, + { label: 'option2', value: 'option2' }, + ]} + value={item.select} + /> + </div> + ); +} diff --git a/app/portainer/components/form-components/InputList/InputList.tsx b/app/portainer/components/form-components/InputList/InputList.tsx new file mode 100644 index 000000000..5855361a7 --- /dev/null +++ b/app/portainer/components/form-components/InputList/InputList.tsx @@ -0,0 +1,183 @@ +import { ComponentType } from 'react'; +import clsx from 'clsx'; + +import { AddButton, Button } from '@/portainer/components/Button'; +import { Tooltip } from '@/portainer/components/Tooltip'; + +import { TextInput } from '../Input'; + +import styles from './InputList.module.css'; +import { arrayMove } from './utils'; + +interface ItemProps<T> { + item: T; + onChange(value: T): void; +} +type Key = string | number; +type ChangeType = 'delete' | 'create' | 'update'; +export type DefaultType = { value: string }; + +type OnChangeEvent<T> = + | { + item: T; + type: ChangeType; + } + | { + type: 'move'; + fromIndex: number; + to: number; + }; + +interface Props<T> { + label: string; + value: T[]; + onChange(value: T[], e: OnChangeEvent<T>): void; + itemBuilder?(): T; + item?: ComponentType<ItemProps<T>>; + tooltip?: string; + addLabel?: string; + itemKeyGetter?(item: T, index: number): Key; + movable?: boolean; +} + +export function InputList<T = DefaultType>({ + label, + value, + onChange, + itemBuilder = (defaultItemBuilder as unknown) as () => T, + item = (DefaultItem as unknown) as ComponentType<ItemProps<T>>, + tooltip, + addLabel = 'Add item', + itemKeyGetter = (item: T, index: number) => index, + movable, +}: Props<T>) { + const Item = item; + + return ( + <div className={clsx('form-group', styles.root)}> + <div className={clsx('col-sm-12', styles.header)}> + <div className={clsx('control-label text-left', styles.label)}> + {label} + {tooltip && <Tooltip message={tooltip} />} + </div> + <AddButton + label={addLabel} + className="space-left" + onClick={handleAdd} + /> + </div> + + <div className={clsx('col-sm-12 form-inline', styles.items)}> + {value.map((item, index) => { + const key = itemKeyGetter(item, index); + + return ( + <div key={key} className={clsx(styles.itemLine)}> + <Item + item={item} + onChange={(value: T) => handleChangeItem(key, value)} + /> + <div className={styles.itemActions}> + {movable && ( + <> + <Button + size="small" + disabled={index === 0} + onClick={() => handleMoveUp(index)} + > + <i className="fa fa-arrow-up" aria-hidden="true" /> + </Button> + <Button + size="small" + type="button" + disabled={index === value.length - 1} + onClick={() => handleMoveDown(index)} + > + <i className="fa fa-arrow-down" aria-hidden="true" /> + </Button> + </> + )} + <Button + color="danger" + size="small" + onClick={() => handleRemoveItem(key, item)} + > + <i className="fa fa-trash" aria-hidden="true" /> + </Button> + </div> + </div> + ); + })} + </div> + </div> + ); + + function handleMoveUp(index: number) { + if (index <= 0) { + return; + } + handleMove(index, index - 1); + } + + function handleMoveDown(index: number) { + if (index >= value.length - 1) { + return; + } + handleMove(index, index + 1); + } + + function handleMove(from: number, to: number) { + if (!movable) { + return; + } + + onChange(arrayMove(value, from, to), { + type: 'move', + fromIndex: from, + to, + }); + } + + function handleRemoveItem(key: Key, item: T) { + onChange( + value.filter((item, index) => { + const itemKey = itemKeyGetter(item, index); + return itemKey !== key; + }), + { type: 'delete', item } + ); + } + + function handleAdd() { + const newItem = itemBuilder(); + onChange([...value, newItem], { type: 'create', item: newItem }); + } + + function handleChangeItem(key: Key, newItemValue: T) { + const newItems = value.map((item, index) => { + const itemKey = itemKeyGetter(item, index); + if (itemKey !== key) { + return item; + } + return newItemValue; + }); + onChange(newItems, { + type: 'update', + item: newItemValue, + }); + } +} + +function defaultItemBuilder(): DefaultType { + return { value: '' }; +} + +function DefaultItem({ item, onChange }: ItemProps<DefaultType>) { + return ( + <TextInput + value={item.value} + onChange={(value: string) => onChange({ value })} + className={styles.defaultItem} + /> + ); +} diff --git a/app/portainer/components/form-components/InputList/index.ts b/app/portainer/components/form-components/InputList/index.ts new file mode 100644 index 000000000..6d613f13a --- /dev/null +++ b/app/portainer/components/form-components/InputList/index.ts @@ -0,0 +1 @@ +export { InputList } from './InputList'; diff --git a/app/portainer/components/form-components/InputList/utils.test.ts b/app/portainer/components/form-components/InputList/utils.test.ts new file mode 100644 index 000000000..cf350e649 --- /dev/null +++ b/app/portainer/components/form-components/InputList/utils.test.ts @@ -0,0 +1,23 @@ +import { arrayMove } from './utils'; + +it('moves items in an array', () => { + expect(arrayMove(['a', 'b', 'c'], 2, 0)).toEqual(['c', 'a', 'b']); + expect( + arrayMove( + [ + { name: 'Fred' }, + { name: 'Barney' }, + { name: 'Wilma' }, + { name: 'Betty' }, + ], + 2, + 1 + ) + ).toEqual([ + { name: 'Fred' }, + { name: 'Wilma' }, + { name: 'Barney' }, + { name: 'Betty' }, + ]); + expect(arrayMove([1, 2, 3], 2, 1)).toEqual([1, 3, 2]); +}); diff --git a/app/portainer/components/form-components/InputList/utils.ts b/app/portainer/components/form-components/InputList/utils.ts new file mode 100644 index 000000000..41b7fc307 --- /dev/null +++ b/app/portainer/components/form-components/InputList/utils.ts @@ -0,0 +1,37 @@ +export function arrayMove<T>(array: Array<T>, from: number, to: number) { + if (!checkValidIndex(array, from) || !checkValidIndex(array, to)) { + throw new Error('index is out of bounds'); + } + + const item = array[from]; + const { length } = array; + + const diff = from - to; + + if (diff > 0) { + // move left + return [ + ...array.slice(0, to), + item, + ...array.slice(to, from), + ...array.slice(from + 1, length), + ]; + } + + if (diff < 0) { + // move right + const targetIndex = to + 1; + return [ + ...array.slice(0, from), + ...array.slice(from + 1, targetIndex), + item, + ...array.slice(targetIndex, length), + ]; + } + + return [...array]; + + function checkValidIndex<T>(array: Array<T>, index: number) { + return index >= 0 && index <= array.length; + } +} diff --git a/yarn.lock b/yarn.lock index d8898b5a3..1bde71174 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12132,7 +12132,7 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@^4.17.21: +lodash-es@^4.17.15, lodash-es@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==