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 'ui-select/dist/select.css';
|
||||||
import 'bootstrap/dist/css/bootstrap.css';
|
import 'bootstrap/dist/css/bootstrap.css';
|
||||||
import '@fortawesome/fontawesome-free/css/brands.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 'angular-multiselect/isteven-multi-select.css';
|
||||||
import 'spinkit/spinkit.min.css';
|
import 'spinkit/spinkit.min.css';
|
||||||
|
|
||||||
|
import './rdash.css';
|
||||||
|
import './app.css';
|
||||||
|
|
||||||
import './theme.css';
|
import './theme.css';
|
||||||
import './vendor-override.css';
|
import './vendor-override.css';
|
||||||
|
|
|
@ -3,14 +3,16 @@ import clsx from 'clsx';
|
||||||
import styles from './AddButton.module.css';
|
import styles from './AddButton.module.css';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
|
className?: string;
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddButton({ label, onClick }: Props) {
|
export function AddButton({ label, onClick, className }: Props) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
className,
|
||||||
'label',
|
'label',
|
||||||
'label-default',
|
'label-default',
|
||||||
'interactive',
|
'interactive',
|
||||||
|
|
|
@ -33,7 +33,7 @@ export function BaseInput({
|
||||||
readOnly={readonly}
|
readOnly={readonly}
|
||||||
required={required}
|
required={required}
|
||||||
type={type}
|
type={type}
|
||||||
className={clsx(className, 'form-control')}
|
className={clsx('form-control', className)}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -31,7 +31,9 @@ export function Select<T extends number | string>({
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
>
|
>
|
||||||
{options.map((item) => (
|
{options.map((item) => (
|
||||||
<option value={item.value}>{item.label}</option>
|
<option value={item.value} key={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import clsx from 'clsx';
|
|
||||||
import { HTMLInputTypeAttribute } from 'react';
|
import { HTMLInputTypeAttribute } from 'react';
|
||||||
|
|
||||||
import { BaseInput } from './BaseInput';
|
import { BaseInput } from './BaseInput';
|
||||||
|
@ -23,7 +22,7 @@ export function TextInput({
|
||||||
<BaseInput
|
<BaseInput
|
||||||
id={id}
|
id={id}
|
||||||
type={type}
|
type={type}
|
||||||
className={clsx(className, 'form-control')}
|
className={className}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
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:
|
dependencies:
|
||||||
p-locate "^5.0.0"
|
p-locate "^5.0.0"
|
||||||
|
|
||||||
lodash-es@^4.17.21:
|
lodash-es@^4.17.15, lodash-es@^4.17.21:
|
||||||
version "4.17.21"
|
version "4.17.21"
|
||||||
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
|
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
|
||||||
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
|
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
|
||||||
|
|
Loading…
Reference in New Issue