mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 07:49:41 +02:00
refactor(app): move react components to react codebase [EE-3179] (#6971)
This commit is contained in:
parent
212400c283
commit
18252ab854
346 changed files with 642 additions and 644 deletions
|
@ -0,0 +1,34 @@
|
|||
.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-line.has-error {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.default-item {
|
||||
width: 100% !important;
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Input, Select } from '../Input';
|
||||
|
||||
import { DefaultType, InputList } from './InputList';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Form/InputList',
|
||||
component: InputList,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export { Defaults, ListWithInputAndSelect };
|
||||
|
||||
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;
|
||||
}
|
||||
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 className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.value}
|
||||
onChange={(e) =>
|
||||
onChange({ ...item, value: parseInt(e.target.value, 10) })
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
onChange={(e) => onChange({ ...item, select: e.target.value })}
|
||||
options={[
|
||||
{ label: 'option1', value: 'option1' },
|
||||
{ label: 'option2', value: 'option2' },
|
||||
]}
|
||||
value={item.select}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
232
app/react/components/form-components/InputList/InputList.tsx
Normal file
232
app/react/components/form-components/InputList/InputList.tsx
Normal file
|
@ -0,0 +1,232 @@
|
|||
import { ComponentType } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { AddButton, Button } from '@@/buttons';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { Input } from '../Input';
|
||||
import { FormError } from '../FormError';
|
||||
|
||||
import styles from './InputList.module.css';
|
||||
import { arrayMove } from './utils';
|
||||
|
||||
export type InputListError<T> = Record<keyof T, string>;
|
||||
|
||||
export interface ItemProps<T> {
|
||||
item: T;
|
||||
onChange(value: T): void;
|
||||
error?: InputListError<T>;
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
type RenderItemFunction<T> = (
|
||||
item: T,
|
||||
onChange: (value: T) => void,
|
||||
error?: InputListError<T>
|
||||
) => React.ReactNode;
|
||||
|
||||
interface Props<T> {
|
||||
label: string;
|
||||
value: T[];
|
||||
onChange(value: T[], e: OnChangeEvent<T>): void;
|
||||
itemBuilder?(): T;
|
||||
renderItem?: RenderItemFunction<T>;
|
||||
item?: ComponentType<ItemProps<T>>;
|
||||
tooltip?: string;
|
||||
addLabel?: string;
|
||||
itemKeyGetter?(item: T, index: number): Key;
|
||||
movable?: boolean;
|
||||
errors?: InputListError<T>[] | string;
|
||||
textTip?: string;
|
||||
isAddButtonHidden?: boolean;
|
||||
}
|
||||
|
||||
export function InputList<T = DefaultType>({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
itemBuilder = defaultItemBuilder as unknown as () => T,
|
||||
renderItem = renderDefaultItem as unknown as RenderItemFunction<T>,
|
||||
item: Item,
|
||||
tooltip,
|
||||
addLabel = 'Add item',
|
||||
itemKeyGetter = (item: T, index: number) => index,
|
||||
movable,
|
||||
errors,
|
||||
textTip,
|
||||
isAddButtonHidden = false,
|
||||
}: Props<T>) {
|
||||
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>
|
||||
{!isAddButtonHidden && (
|
||||
<AddButton
|
||||
label={addLabel}
|
||||
className="space-left"
|
||||
onClick={handleAdd}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{textTip && (
|
||||
<div className="col-sm-12 my-5">
|
||||
<TextTip color="blue">{textTip}</TextTip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={clsx('col-sm-12', styles.items, 'space-y-4')}>
|
||||
{value.map((item, index) => {
|
||||
const key = itemKeyGetter(item, index);
|
||||
const error = typeof errors === 'object' ? errors[index] : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={clsx(styles.itemLine, { [styles.hasError]: !!error })}
|
||||
>
|
||||
{Item ? (
|
||||
<Item
|
||||
item={item}
|
||||
onChange={(value: T) => handleChangeItem(key, value)}
|
||||
error={error}
|
||||
/>
|
||||
) : (
|
||||
renderItem(
|
||||
item,
|
||||
(value: T) => handleChangeItem(key, value),
|
||||
error
|
||||
)
|
||||
)}
|
||||
<div className={clsx(styles.itemActions, 'items-start')}>
|
||||
{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, error }: ItemProps<DefaultType>) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) => onChange({ value: e.target.value })}
|
||||
className={styles.defaultItem}
|
||||
/>
|
||||
{error && <FormError>{error}</FormError>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDefaultItem(
|
||||
item: DefaultType,
|
||||
onChange: (value: DefaultType) => void,
|
||||
error?: InputListError<DefaultType>
|
||||
) {
|
||||
return <DefaultItem item={item} onChange={onChange} error={error} />;
|
||||
}
|
1
app/react/components/form-components/InputList/index.ts
Normal file
1
app/react/components/form-components/InputList/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { InputList } from './InputList';
|
23
app/react/components/form-components/InputList/utils.test.ts
Normal file
23
app/react/components/form-components/InputList/utils.test.ts
Normal 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]);
|
||||
});
|
37
app/react/components/form-components/InputList/utils.ts
Normal file
37
app/react/components/form-components/InputList/utils.ts
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue