1
0
Fork 0
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:
Chaim Lev-Ari 2022-06-17 19:18:42 +03:00 committed by GitHub
parent 212400c283
commit 18252ab854
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
346 changed files with 642 additions and 644 deletions

View file

@ -0,0 +1,10 @@
.group input {
display: none;
}
.group input:checked + label {
color: #fff;
background-color: #286090;
background-image: none;
border-color: #204d74;
}

View file

@ -0,0 +1,31 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { ButtonSelector, Option } from './ButtonSelector';
export default {
component: ButtonSelector,
title: 'Components/ButtonSelector',
} as Meta;
export { TwoOptionsSelector };
function TwoOptionsSelector() {
const options: Option<string>[] = [
{ value: 'sAMAccountName', label: 'username' },
{ value: 'userPrincipalName', label: 'user@domainname' },
];
const [value, setValue] = useState('sAMAccountName');
return (
<ButtonSelector<string>
onChange={handleChange}
value={value}
options={options}
/>
);
function handleChange(value: string) {
setValue(value);
}
}

View file

@ -0,0 +1,57 @@
import clsx from 'clsx';
import { PropsWithChildren, ReactNode } from 'react';
import { ButtonGroup, Size } from '@@/buttons/ButtonGroup';
import styles from './ButtonSelector.module.css';
export interface Option<T> {
value: T;
label?: ReactNode;
}
interface Props<T> {
value: T;
onChange(value: T): void;
options: Option<T>[];
size?: Size;
}
export function ButtonSelector<T extends string | number>({
value,
onChange,
size,
options,
}: Props<T>) {
return (
<ButtonGroup size={size} className={styles.group}>
{options.map((option) => (
<OptionItem
key={option.value}
selected={value === option.value}
onChange={() => onChange(option.value)}
>
{option.label || option.value.toString()}
</OptionItem>
))}
</ButtonGroup>
);
}
interface OptionItemProps {
selected: boolean;
onChange(): void;
}
function OptionItem({
selected,
children,
onChange,
}: PropsWithChildren<OptionItemProps>) {
return (
<label className={clsx('btn btn-primary', { active: selected })}>
{children}
<input type="radio" checked={selected} onChange={onChange} />
</label>
);
}

View file

@ -0,0 +1,57 @@
import {
forwardRef,
useRef,
useEffect,
MutableRefObject,
ChangeEventHandler,
HTMLProps,
} from 'react';
interface Props extends HTMLProps<HTMLInputElement> {
checked?: boolean;
indeterminate?: boolean;
title?: string;
label?: string;
id: string;
className?: string;
role?: string;
onChange?: ChangeEventHandler<HTMLInputElement>;
}
export const Checkbox = forwardRef<HTMLInputElement, Props>(
(
{ indeterminate, title, label, id, checked, onChange, ...props }: Props,
ref
) => {
const defaultRef = useRef<HTMLInputElement>(null);
let resolvedRef = ref as MutableRefObject<HTMLInputElement | null>;
if (!ref) {
resolvedRef = defaultRef;
}
useEffect(() => {
if (resolvedRef === null || resolvedRef.current === null) {
return;
}
if (typeof indeterminate !== 'undefined') {
resolvedRef.current.indeterminate = indeterminate;
}
}, [resolvedRef, indeterminate]);
return (
<div className="md-checkbox" title={title || label}>
<input
id={id}
type="checkbox"
ref={resolvedRef}
onChange={onChange}
checked={checked}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
<label htmlFor={id}>{label}</label>
</div>
);
}
);

View file

@ -0,0 +1,7 @@
.file-input {
display: none !important;
}
.file-button {
margin-left: 0 !important;
}

View file

@ -0,0 +1,33 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { FileUploadField } from './FileUploadField';
export default {
component: FileUploadField,
title: 'Components/Buttons/FileUploadField',
} as Meta;
interface Args {
title: string;
}
export { Example };
function Example({ title }: Args) {
const [value, setValue] = useState<File>();
function onChange(value: File) {
if (value) {
setValue(value);
}
}
return (
<FileUploadField
onChange={onChange}
value={value}
title={title}
inputId="file-field"
/>
);
}

View file

@ -0,0 +1,28 @@
import { fireEvent, render } from '@/react-tools/test-utils';
import { FileUploadField } from './FileUploadField';
test('render should make the file button clickable and fire onChange event after click', async () => {
const onClick = jest.fn();
const { findByText, findByLabelText } = render(
<FileUploadField
title="test button"
onChange={onClick}
inputId="file-field"
/>
);
const button = await findByText('test button');
expect(button).toBeVisible();
const input = await findByLabelText('file-input');
expect(input).not.toBeNull();
const mockFile = new File([], 'file.txt');
if (input) {
fireEvent.change(input, {
target: { files: [mockFile] },
});
}
expect(onClick).toHaveBeenCalledWith(mockFile);
});

View file

@ -0,0 +1,68 @@
import { ChangeEvent, createRef } from 'react';
import { Button } from '@@/buttons';
import styles from './FileUploadField.module.css';
export interface Props {
onChange(value: File): void;
value?: File;
accept?: string;
title?: string;
required?: boolean;
inputId: string;
}
export function FileUploadField({
onChange,
value,
accept,
title = 'Select a file',
required = false,
inputId,
}: Props) {
const fileRef = createRef<HTMLInputElement>();
return (
<div className="file-upload-field">
<input
id={inputId}
ref={fileRef}
type="file"
accept={accept}
required={required}
className={styles.fileInput}
onChange={changeHandler}
aria-label="file-input"
/>
<Button
size="small"
color="primary"
onClick={handleButtonClick}
className={styles.fileButton}
>
{title}
</Button>
<span className="space-left">
{value ? (
value.name
) : (
<i className="fa fa-times red-icon" aria-hidden="true" />
)}
</span>
</div>
);
function handleButtonClick() {
if (fileRef && fileRef.current) {
fileRef.current.click();
}
}
function changeHandler(event: ChangeEvent<HTMLInputElement>) {
if (event.target && event.target.files && event.target.files.length > 0) {
onChange(event.target.files[0]);
}
}
}

View file

@ -0,0 +1,37 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { FileUploadForm } from './FileUploadForm';
export default {
component: FileUploadForm,
title: 'Components/Form/FileUploadForm',
} as Meta;
interface Args {
title: string;
}
export { Example };
function Example({ title }: Args) {
const [value, setValue] = useState<File>();
function onChange(value: File) {
if (value) {
setValue(value);
}
}
return (
<div className="form-horizontal">
<FileUploadForm
onChange={onChange}
value={value}
title={title}
description={
<span>You can upload a Compose file from your computer.</span>
}
/>
</div>
);
}

View file

@ -0,0 +1,20 @@
import { render } from '@/react-tools/test-utils';
import { FileUploadForm } from './FileUploadForm';
test('render should include description', async () => {
const onClick = jest.fn();
const { findByText } = render(
<FileUploadForm
title="test button"
onChange={onClick}
description={<span>test description</span>}
/>
);
const button = await findByText('test button');
expect(button).toBeVisible();
const description = await findByText('test description');
expect(description).toBeVisible();
});

View file

@ -0,0 +1,40 @@
import { PropsWithChildren, ReactNode } from 'react';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { FileUploadField } from '@@/form-components/FileUpload/FileUploadField';
export interface Props {
onChange(value: unknown): void;
value?: File;
title?: string;
required?: boolean;
description: ReactNode;
}
export function FileUploadForm({
onChange,
value,
title = 'Select a file',
required = false,
description,
}: PropsWithChildren<Props>) {
return (
<div className="file-upload-form">
<FormSectionTitle>Upload</FormSectionTitle>
<div className="form-group">
<span className="col-sm-12 text-muted small">{description}</span>
</div>
<div className="form-group">
<div className="col-sm-12">
<FileUploadField
inputId="file-upload-field"
onChange={onChange}
value={value}
title={title}
required={required}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,2 @@
export { FileUploadField } from './FileUploadField';
export { FileUploadForm } from './FileUploadForm';

View file

@ -0,0 +1,61 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { Input, Select } from '../Input';
import { FormControl } from './FormControl';
export default {
title: 'Components/Form/Control',
} as Meta;
interface TextFieldProps {
label: string;
tooltip?: string;
}
export { TextField, SelectField };
function TextField({ label, tooltip = '' }: TextFieldProps) {
const [value, setValue] = useState('');
const inputId = 'input';
return (
<FormControl inputId={inputId} label={label} tooltip={tooltip}>
<Input
id={inputId}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</FormControl>
);
}
TextField.args = {
label: 'label',
tooltip: '',
};
function SelectField({ label, tooltip = '' }: TextFieldProps) {
const options = [
{ value: 1, label: 'one' },
{ value: 2, label: 'two' },
];
const [value, setValue] = useState(0);
const inputId = 'input';
return (
<FormControl inputId={inputId} label={label} tooltip={tooltip}>
<Select
className="form-control"
value={value}
onChange={(e) => setValue(parseInt(e.target.value, 10))}
options={options}
/>
</FormControl>
);
}
SelectField.args = {
label: 'select',
tooltip: '',
};

View file

@ -0,0 +1,30 @@
import { render } from '@testing-library/react';
import { FormControl, Props } from './FormControl';
function renderDefault({
inputId = 'id',
label,
tooltip = '',
errors,
}: Partial<Props>) {
return render(
<FormControl
inputId={inputId}
label={label}
tooltip={tooltip}
errors={errors}
>
<input />
</FormControl>
);
}
test('should display a Input component', async () => {
const label = 'test label';
const { findByText } = renderDefault({ label });
const inputElem = await findByText(label);
expect(inputElem).toBeTruthy();
});

View file

@ -0,0 +1,78 @@
import { PropsWithChildren, ReactNode } from 'react';
import clsx from 'clsx';
import { Tooltip } from '@@/Tip/Tooltip';
import { FormError } from '../FormError';
import styles from './FormControl.module.css';
type Size = 'small' | 'medium' | 'large';
export interface Props {
inputId?: string;
label: string | ReactNode;
size?: Size;
tooltip?: string;
children: ReactNode;
errors?: string | ReactNode;
required?: boolean;
}
export function FormControl({
inputId,
label,
size = 'small',
tooltip = '',
children,
errors,
required,
}: PropsWithChildren<Props>) {
return (
<>
<div className={clsx('form-group', styles.container)}>
<label
htmlFor={inputId}
className={clsx(sizeClassLabel(size), 'control-label', 'text-left')}
>
{label}
{required && <span className="text-danger">*</span>}
{tooltip && <Tooltip message={tooltip} />}
</label>
<div className={sizeClassChildren(size)}>{children}</div>
</div>
{errors && (
<div className="form-group">
<div className="col-md-12">
<FormError>{errors}</FormError>
</div>
</div>
)}
</>
);
}
function sizeClassLabel(size?: Size) {
switch (size) {
case 'large':
return 'col-sm-5 col-lg-4';
case 'medium':
return 'col-sm-4 col-lg-3';
default:
return 'col-sm-3 col-lg-2';
}
}
function sizeClassChildren(size?: Size) {
switch (size) {
case 'large':
return 'col-sm-7 col-lg-8';
case 'medium':
return 'col-sm-8 col-lg-9';
default:
return 'col-sm-9 col-lg-10';
}
}

View file

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

View file

@ -0,0 +1,13 @@
import { PropsWithChildren } from 'react';
export function FormError({ children }: PropsWithChildren<unknown>) {
return (
<div className="small text-warning">
<i
className="fa fa-exclamation-triangle space-right"
aria-hidden="true"
/>
{children}
</div>
);
}

View file

@ -0,0 +1,43 @@
import { Meta, Story } from '@storybook/react';
import { FormSection } from './FormSection';
export default {
component: FormSection,
title: 'Components/Form/FormSection',
} as Meta;
interface Args {
title: string;
content: string;
}
function Template({ title, content }: Args) {
return <FormSection title={title}>{content}</FormSection>;
}
const exampleContent = `Content
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam egestas turpis magna,
vel pretium dui rhoncus nec. Maecenas felis purus, consectetur non porta sit amet,
auctor sed sapien. Aliquam eu nunc felis. Pellentesque pulvinar velit id quam pellentesque,
nec imperdiet dui finibus. In blandit augue nibh, nec tincidunt nisi porttitor quis.
Nullam nec nibh maximus, consequat quam sed, dapibus purus. Donec facilisis commodo mi, in commodo augue molestie sed.
`;
export const Example: Story<Args> = Template.bind({});
Example.args = {
title: 'title',
content: exampleContent,
};
export function FoldableSection({
title = 'title',
content = exampleContent,
}: Args) {
return (
<FormSection title={title} isFoldable>
{content}
</FormSection>
);
}

View file

@ -0,0 +1,40 @@
import { PropsWithChildren, useState } from 'react';
import { FormSectionTitle } from '../FormSectionTitle';
interface Props {
title: string;
isFoldable?: boolean;
}
export function FormSection({
title,
children,
isFoldable = false,
}: PropsWithChildren<Props>) {
const [isExpanded, setIsExpanded] = useState(!isFoldable);
return (
<>
<FormSectionTitle htmlFor={isFoldable ? `foldingButton${title}` : ''}>
{isFoldable && (
<button
id={`foldingButton${title}`}
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="border-0 mx-2 bg-transparent inline-flex justify-center items-center w-2"
>
<i
className={`fa fa-caret-${isExpanded ? 'down' : 'right'}`}
aria-hidden="true"
/>
</button>
)}
{title}
</FormSectionTitle>
{isExpanded && children}
</>
);
}

View file

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

View file

@ -0,0 +1,20 @@
import { Meta, Story } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { FormSectionTitle } from './FormSectionTitle';
export default {
component: FormSectionTitle,
title: 'Components/Form/FormSectionTitle',
} as Meta;
function Template({
children,
}: JSX.IntrinsicAttributes & PropsWithChildren<unknown>) {
return <FormSectionTitle>{children}</FormSectionTitle>;
}
export const Example: Story<PropsWithChildren<unknown>> = Template.bind({});
Example.args = {
children: 'This is a title with children',
};

View file

@ -0,0 +1,13 @@
import { render } from '@testing-library/react';
import { FormSectionTitle } from './FormSectionTitle';
test('should display a FormSectionTitle with children', async () => {
const children = 'test form title';
const { findByText } = render(
<FormSectionTitle>{children}</FormSectionTitle>
);
const heading = await findByText(children);
expect(heading).toBeTruthy();
});

View file

@ -0,0 +1,22 @@
import { PropsWithChildren } from 'react';
interface Props {
htmlFor?: string;
}
export function FormSectionTitle({
children,
htmlFor,
}: PropsWithChildren<Props>) {
if (htmlFor) {
return (
<label
htmlFor={htmlFor}
className="col-sm-12 form-section-title cursor-pointer"
>
{children}
</label>
);
}
return <div className="col-sm-12 form-section-title">{children}</div>;
}

View file

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

View file

@ -0,0 +1,32 @@
import { Meta, Story } from '@storybook/react';
import { useState } from 'react';
import { Input } from './Input';
export default {
title: 'Components/Form/Input',
args: {
disabled: false,
},
} as Meta;
interface Args {
disabled?: boolean;
}
export function TextField({ disabled }: Args) {
const [value, setValue] = useState('');
return (
<Input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={disabled}
/>
);
}
export const DisabledTextField: Story<Args> = TextField.bind({});
DisabledTextField.args = {
disabled: true,
};

View file

@ -0,0 +1,15 @@
import clsx from 'clsx';
import { InputHTMLAttributes } from 'react';
export function Input({
className,
...props
}: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
className={clsx('form-control', className)}
/>
);
}

View file

@ -0,0 +1,36 @@
import { Meta, Story } from '@storybook/react';
import { useState } from 'react';
import { Select } from './Select';
export default {
title: 'Components/Form/Select',
args: {
disabled: false,
},
} as Meta;
interface Args {
disabled?: boolean;
}
export function Example({ disabled }: Args) {
const [value, setValue] = useState(0);
const options = [
{ value: 1, label: 'one' },
{ value: 2, label: 'two' },
];
return (
<Select
value={value}
onChange={(e) => setValue(parseInt(e.target.value, 10))}
disabled={disabled}
options={options}
/>
);
}
export const DisabledSelect: Story<Args> = Example.bind({});
DisabledSelect.args = {
disabled: true,
};

View file

@ -0,0 +1,31 @@
import clsx from 'clsx';
import { SelectHTMLAttributes } from 'react';
export interface Option<T extends string | number> {
value: T;
label: string;
}
interface Props<T extends string | number> {
options: Option<T>[];
}
export function Select<T extends number | string>({
options,
className,
...props
}: Props<T> & SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
className={clsx('form-control', className)}
>
{options.map((item) => (
<option value={item.value} key={item.value}>
{item.label}
</option>
))}
</select>
);
}

View file

@ -0,0 +1,15 @@
import clsx from 'clsx';
import { TextareaHTMLAttributes } from 'react';
export function TextArea({
className,
...props
}: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
className={clsx('form-control', className)}
/>
);
}

View file

@ -0,0 +1,2 @@
export { Input } from './Input';
export { Select } from './Select';

View file

@ -0,0 +1,126 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { InputGroup } from '.';
export default {
component: InputGroup,
title: 'Components/Form/InputGroup',
} as Meta;
export { BasicExample, Addons, Sizing };
function BasicExample() {
const [value1, setValue1] = useState('');
const [valueNumber, setValueNumber] = useState(0);
return (
<div className="space-y-8">
<InputGroup>
<InputGroup.Addon>@</InputGroup.Addon>
<InputGroup.Input
value={value1}
onChange={(e) => setValue1(e.target.value)}
placeholder="Username"
aria-describedby="basic-addon1"
/>
</InputGroup>
<InputGroup>
<InputGroup.Input
value={value1}
onChange={(e) => setValue1(e.target.value)}
placeholder="Recipient's username"
aria-describedby="basic-addon2"
/>
<InputGroup.Addon>@example.com</InputGroup.Addon>
</InputGroup>
<InputGroup>
<InputGroup.Addon>$</InputGroup.Addon>
<InputGroup.Input
type="number"
value={valueNumber}
onChange={(e) => setValueNumber(parseInt(e.target.value, 10))}
aria-label="Amount (to the nearest dollar)"
/>
<InputGroup.Addon>.00</InputGroup.Addon>
</InputGroup>
<label htmlFor="basic-url">Your vanity URL</label>
<InputGroup>
<InputGroup.Addon>https://example.com/users/</InputGroup.Addon>
<InputGroup.Input
value={value1}
onChange={(e) => setValue1(e.target.value)}
id="basic-url"
aria-describedby="basic-addon3"
/>
</InputGroup>
</div>
);
}
function Addons() {
const [value1, setValue1] = useState('');
const [value2, setValue2] = useState('');
return (
<div className="row">
<div className="col-lg-6">
<InputGroup>
<InputGroup.ButtonWrapper>
<button className="btn btn-default" type="button">
Go!
</button>
</InputGroup.ButtonWrapper>
<InputGroup.Input
value={value1}
onChange={(e) => setValue1(e.target.value)}
/>
</InputGroup>
</div>
<div className="col-lg-6">
<InputGroup>
<InputGroup.Input
value={value2}
onChange={(e) => setValue2(e.target.value)}
/>
<InputGroup.Addon>
<input type="checkbox" />
</InputGroup.Addon>
</InputGroup>
</div>
</div>
);
}
function Sizing() {
const [value, setValue] = useState('');
return (
<div className="space-y-8">
<InputGroup size="small">
<InputGroup.Addon>Small</InputGroup.Addon>
<InputGroup.Input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</InputGroup>
<InputGroup>
<InputGroup.Addon>Default</InputGroup.Addon>
<InputGroup.Input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</InputGroup>
<InputGroup size="large">
<InputGroup.Addon>Large</InputGroup.Addon>
<InputGroup.Input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</InputGroup>
</div>
);
}

View file

@ -0,0 +1,37 @@
import clsx from 'clsx';
import { createContext, PropsWithChildren, useContext } from 'react';
const Context = createContext<null | boolean>(null);
type Size = 'small' | 'large';
export function useInputGroupContext() {
const context = useContext(Context);
if (context == null) {
throw new Error('Should be inside a InputGroup component');
}
}
interface Props {
size?: Size;
}
export function InputGroup({ children, size }: PropsWithChildren<Props>) {
return (
<Context.Provider value>
<div className={clsx('input-group', sizeClass(size))}>{children}</div>
</Context.Provider>
);
}
function sizeClass(size?: Size) {
switch (size) {
case 'large':
return 'input-group-lg';
case 'small':
return 'input-group-sm';
default:
return '';
}
}

View file

@ -0,0 +1,9 @@
import { PropsWithChildren } from 'react';
import { useInputGroupContext } from './InputGroup';
export function InputGroupAddon({ children }: PropsWithChildren<unknown>) {
useInputGroupContext();
return <span className="input-group-addon">{children}</span>;
}

View file

@ -0,0 +1,11 @@
import { PropsWithChildren } from 'react';
import { useInputGroupContext } from './InputGroup';
export function InputGroupButtonWrapper({
children,
}: PropsWithChildren<unknown>) {
useInputGroupContext();
return <span className="input-group-btn">{children}</span>;
}

View file

@ -0,0 +1,20 @@
import { Input } from '../Input';
import { InputGroup as MainComponent } from './InputGroup';
import { InputGroupAddon } from './InputGroupAddon';
import { InputGroupButtonWrapper } from './InputGroupButtonWrapper';
interface InputGroupSubComponents {
Addon: typeof InputGroupAddon;
ButtonWrapper: typeof InputGroupButtonWrapper;
Input: typeof Input;
}
const InputGroup: typeof MainComponent & InputGroupSubComponents =
MainComponent as typeof MainComponent & InputGroupSubComponents;
InputGroup.Addon = InputGroupAddon;
InputGroup.ButtonWrapper = InputGroupButtonWrapper;
InputGroup.Input = Input;
export { InputGroup };

View file

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

View file

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

View 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} />;
}

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

@ -0,0 +1,63 @@
.root :global .selector__control {
border: 1px solid var(--border-multiselect);
background-color: var(--bg-multiselect-color);
}
.root :global .selector__multi-value {
background-color: var(--grey-51);
}
:global :root[theme='dark'] :local .root :global .selector__multi-value {
background-color: var(--grey-3);
}
:global :root[theme='highcontrast'] :local .root :global .selector__multi-value {
background-color: var(--grey-3);
}
.root :global .selector__multi-value__label {
color: var(--black-color);
}
:global :root[theme='dark'] :local .root :global .selector__multi-value__label {
color: var(--white-color);
}
:global :root[theme='highcontrast'] :local .root :global .selector__multi-value__label {
color: var(--white-color);
}
.root :global .selector__menu {
background-color: var(--bg-multiselect-color);
border: 1px solid var(--border-multiselect);
}
.root :global .selector__option {
background-color: var(--bg-multiselect-color);
border: 1px solid var(--border-multiselect);
}
.root :global .selector__option:active,
.root :global .selector__option--is-focused {
background-color: var(--blue-8);
}
:global :root[theme='dark'] :local .root :global .selector__option:active,
:global :root[theme='dark'] :local .root :global .selector__option--is-focused {
background-color: var(--blue-2);
color: var(--white-color);
}
.root :global .selector__option--is-selected {
color: var(--grey-7);
}
:global :root[theme='highcontrast'] :local .root :global .selector__single-value,
:global :root[theme='dark'] :local .root :global .selector__single-value {
color: var(--white-color);
}
:global :root[theme='highcontrast'] :local .root :global .selector__input-container,
:global :root[theme='dark'] :local .root :global .selector__input-container {
color: var(--white-color);
}

View file

@ -0,0 +1,41 @@
import ReactSelectCreatable, { CreatableProps } from 'react-select/creatable';
import ReactSelect, { GroupBase, Props as SelectProps } from 'react-select';
import clsx from 'clsx';
import { RefAttributes } from 'react';
import ReactSelectType from 'react-select/dist/declarations/src/Select';
import styles from './ReactSelect.module.css';
export function Select<
Option = unknown,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({
className,
...props
}: SelectProps<Option, IsMulti, Group> &
RefAttributes<ReactSelectType<Option, IsMulti, Group>>) {
return (
<ReactSelect
className={clsx(styles.root, className)}
classNamePrefix="selector"
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
);
}
export function Creatable<
Option = unknown,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({ className, ...props }: CreatableProps<Option, IsMulti, Group>) {
return (
<ReactSelectCreatable
className={clsx(styles.root, className)}
classNamePrefix="selector"
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
);
}

View file

@ -0,0 +1,32 @@
.root {
margin: 0 25px;
}
.slider {
padding-top: 50px;
}
.slider :global .rc-slider-handle {
width: 32px;
height: 32px;
margin-top: -14px;
border-radius: 16px;
cursor: pointer;
background-color: #0db9f0;
}
.slider :global .rc-slider-handle:after {
position: absolute;
top: 10px;
left: 10px;
width: 8px;
height: 8px;
background: #ffffff;
border-radius: 4px;
content: '';
}
.slider :global .rc-slider-mark-text,
.slider :global .rc-slider-tooltip-inner {
font-family: Montserrat, serif;
}

View file

@ -0,0 +1,35 @@
import { Meta, Story } from '@storybook/react';
import { useEffect, useState } from 'react';
import { Slider, Props } from './Slider';
export default {
component: Slider,
title: 'Components/Form/Slider',
} as Meta;
function Template({ value, min, max, step }: JSX.IntrinsicAttributes & Props) {
const [sliderValue, setSliderValue] = useState(min);
useEffect(() => {
setSliderValue(value);
}, [value]);
return (
<Slider
min={min}
max={max}
step={step}
value={sliderValue}
onChange={setSliderValue}
/>
);
}
export const Primary: Story<Props> = Template.bind({});
Primary.args = {
min: 0,
max: 100,
step: 1,
value: 5,
};

View file

@ -0,0 +1,22 @@
import { render } from '@/react-tools/test-utils';
import { Slider, Props } from './Slider';
function renderDefault({
min = 0,
max = 10,
step = 1,
value = min,
onChange = () => {},
}: Partial<Props> = {}) {
return render(
<Slider min={min} max={max} step={step} onChange={onChange} value={value} />
);
}
test('should display a Slider component', async () => {
const { getByRole } = renderDefault({});
const handle = getByRole('slider');
expect(handle).toBeTruthy();
});

View file

@ -0,0 +1,42 @@
import RcSlider from 'rc-slider';
import styles from './Slider.module.css';
import 'rc-slider/assets/index.css';
export interface Props {
min: number;
max: number;
step: number;
value: number;
onChange: (value: number) => void;
}
export function Slider({ min, max, step, value, onChange }: Props) {
const SliderWithTooltip = RcSlider.createSliderWithTooltip(RcSlider);
const marks = {
[min]: translateMinValue(min),
[max]: max.toString(),
};
return (
<div className={styles.root}>
<SliderWithTooltip
tipFormatter={translateMinValue}
min={min}
max={max}
step={step}
marks={marks}
defaultValue={value}
onAfterChange={onChange}
className={styles.slider}
/>
</div>
);
}
function translateMinValue(value: number) {
if (value === 0) {
return 'unlimited';
}
return value.toString();
}

View file

@ -0,0 +1,68 @@
/* switch box */
.switch,
.bootbox-checkbox-list > .checkbox > label {
--switch-size: 24px;
}
.switch.small {
--switch-size: 12px;
}
.switch input {
display: none;
}
.switch i,
.bootbox-form .checkbox i {
display: inline-block;
vertical-align: middle;
cursor: pointer;
padding-right: var(--switch-size);
transition: all ease 0.2s;
-webkit-transition: all ease 0.2s;
-moz-transition: all ease 0.2s;
-o-transition: all ease 0.2s;
border-radius: var(--switch-size);
box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, 0.5);
}
.switch i:before,
.bootbox-form .checkbox i:before {
display: block;
content: '';
width: var(--switch-size);
height: var(--switch-size);
border-radius: var(--switch-size);
background: white;
box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.5);
}
.switch :checked + i,
.bootbox-form .checkbox :checked ~ i {
padding-right: 0;
padding-left: var(--switch-size);
-webkit-box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.5), inset 0 0 40px #337ab7;
-moz-box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.5), inset 0 0 40px #337ab7;
box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.5), inset 0 0 40px #337ab7;
}
.switch :disabled + i {
opacity: 0.5;
cursor: not-allowed;
}
.switch.limited {
pointer-events: none;
touch-action: none;
}
.switch.limited i {
opacity: 1;
cursor: not-allowed;
}
.switch.business i {
background-color: var(--BE-only);
box-shadow: inset 0 0 1px rgb(0 0 0 / 50%), inset 0 0 40px var(--BE-only);
}

View file

@ -0,0 +1,3 @@
.root {
margin-bottom: 0;
}

View file

@ -0,0 +1,35 @@
import { Meta, Story } from '@storybook/react';
import { useState } from 'react';
import { Switch } from './Switch';
export default {
title: 'Components/Form/SwitchField/Switch',
} as Meta;
export function Example() {
const [isChecked, setIsChecked] = useState(false);
function onChange() {
setIsChecked(!isChecked);
}
return <Switch name="name" checked={isChecked} onChange={onChange} id="id" />;
}
interface Args {
checked: boolean;
}
function Template({ checked }: Args) {
return <Switch name="name" checked={checked} onChange={() => {}} id="id" />;
}
export const Checked: Story<Args> = Template.bind({});
Checked.args = {
checked: true,
};
export const Unchecked: Story<Args> = Template.bind({});
Unchecked.args = {
checked: false,
};

View file

@ -0,0 +1,20 @@
import { render } from '@testing-library/react';
import { PropsWithChildren } from 'react';
import { Switch, Props } from './Switch';
function renderDefault({
name = 'default name',
checked = false,
}: Partial<PropsWithChildren<Props>> = {}) {
return render(
<Switch id="id" name={name} checked={checked} onChange={() => {}} />
);
}
test('should display a Switch component', async () => {
const { findByRole } = renderDefault();
const switchElem = await findByRole('checkbox');
expect(switchElem).toBeTruthy();
});

View file

@ -0,0 +1,57 @@
import clsx from 'clsx';
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
import { FeatureId } from '@/portainer/feature-flags/enums';
import { BEFeatureIndicator } from '@@/BEFeatureIndicator';
import './Switch.css';
import styles from './Switch.module.css';
export interface Props {
checked: boolean;
id: string;
name: string;
onChange(checked: boolean): void;
className?: string;
dataCy?: string;
disabled?: boolean;
featureId?: FeatureId;
}
export function Switch({
name,
checked,
id,
disabled,
dataCy,
onChange,
featureId,
className,
}: Props) {
const limitedToBE = isLimitedToBE(featureId);
return (
<>
<label
className={clsx('switch', className, styles.root, {
business: limitedToBE,
limited: limitedToBE,
})}
>
<input
type="checkbox"
name={name}
id={id}
checked={checked}
disabled={disabled || limitedToBE}
onChange={({ target: { checked } }) => onChange(checked)}
/>
<i data-cy={dataCy} />
</label>
{limitedToBE && <BEFeatureIndicator featureId={featureId} />}
</>
);
}

View file

@ -0,0 +1,9 @@
.root {
display: flex;
align-items: center;
margin: 0;
}
.label {
padding: 0;
}

View file

@ -0,0 +1,56 @@
import { Meta, Story } from '@storybook/react';
import { useState } from 'react';
import { SwitchField } from './SwitchField';
export default {
title: 'Components/Form/SwitchField',
} as Meta;
export function Example() {
const [isChecked, setIsChecked] = useState(false);
function onChange() {
setIsChecked(!isChecked);
}
return (
<SwitchField
name="name"
checked={isChecked}
onChange={onChange}
label="Example"
/>
);
}
interface Args {
checked: boolean;
label: string;
labelClass: string;
}
function Template({ checked, label, labelClass }: Args) {
return (
<SwitchField
name="name"
checked={checked}
onChange={() => {}}
label={label}
labelClass={labelClass}
/>
);
}
export const Checked: Story<Args> = Template.bind({});
Checked.args = {
checked: true,
label: 'label',
labelClass: 'col-sm-6',
};
export const Unchecked: Story<Args> = Template.bind({});
Unchecked.args = {
checked: false,
label: 'label',
labelClass: 'col-sm-6',
};

View file

@ -0,0 +1,37 @@
import { render, fireEvent } from '@/react-tools/test-utils';
import { SwitchField, Props } from './SwitchField';
function renderDefault({
name = 'default name',
checked = false,
label = 'label',
onChange = jest.fn(),
}: Partial<Props> = {}) {
return render(
<SwitchField
label={label}
name={name}
checked={checked}
onChange={onChange}
/>
);
}
test('should display a Switch component', async () => {
const { findByRole } = renderDefault();
const switchElem = await findByRole('checkbox');
expect(switchElem).toBeTruthy();
});
test('clicking should emit on-change with the opposite value', async () => {
const onChange = jest.fn();
const checked = true;
const { findByRole } = renderDefault({ onChange, checked });
const switchElem = await findByRole('checkbox');
fireEvent.click(switchElem);
expect(onChange).toHaveBeenCalledWith(!checked);
});

View file

@ -0,0 +1,60 @@
import clsx from 'clsx';
import { FeatureId } from '@/portainer/feature-flags/enums';
import { Tooltip } from '@@/Tip/Tooltip';
import styles from './SwitchField.module.css';
import { Switch } from './Switch';
export interface Props {
label: string;
checked: boolean;
onChange(value: boolean): void;
name?: string;
tooltip?: string;
labelClass?: string;
dataCy?: string;
disabled?: boolean;
featureId?: FeatureId;
}
export function SwitchField({
tooltip,
checked,
label,
name,
labelClass,
dataCy,
disabled,
onChange,
featureId,
}: Props) {
const toggleName = name ? `toggle_${name}` : '';
return (
<label className={styles.root}>
<span
className={clsx(
'control-label text-left space-right',
styles.label,
labelClass
)}
>
{label}
{tooltip && <Tooltip position="bottom" message={tooltip} />}
</span>
<Switch
className="space-right"
name={toggleName}
id={toggleName}
checked={checked}
disabled={disabled}
onChange={onChange}
featureId={featureId}
dataCy={dataCy}
/>
</label>
);
}

View file

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