feat(app): introduce input list component [EE-2003] (#6123)

pull/6116/head
Chaim Lev-Ari 2021-11-22 18:13:40 +02:00 committed by GitHub
parent cea634a7aa
commit c0a4727114
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 376 additions and 9 deletions

View File

@ -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';

View File

@ -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',

View File

@ -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}
/>

View File

@ -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>
);

View File

@ -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}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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}
/>
);
}

View File

@ -0,0 +1 @@
export { InputList } from './InputList';

View File

@ -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]);
});

View File

@ -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;
}
}

View File

@ -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==