mirror of https://github.com/portainer/portainer
feat(app): introduce input list component [EE-2003] (#6123)
parent
cea634a7aa
commit
c0a4727114
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { InputList } from './InputList';
|
|
@ -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]);
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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==
|
||||
|
|
Loading…
Reference in New Issue