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,10 @@
|
|||
.group input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.group input:checked + label {
|
||||
color: #fff;
|
||||
background-color: #286090;
|
||||
background-image: none;
|
||||
border-color: #204d74;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
57
app/react/components/form-components/Checkbox.tsx
Normal file
57
app/react/components/form-components/Checkbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -0,0 +1,7 @@
|
|||
.file-input {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.file-button {
|
||||
margin-left: 0 !important;
|
||||
}
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
});
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
2
app/react/components/form-components/FileUpload/index.ts
Normal file
2
app/react/components/form-components/FileUpload/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { FileUploadField } from './FileUploadField';
|
||||
export { FileUploadForm } from './FileUploadForm';
|
|
@ -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: '',
|
||||
};
|
|
@ -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();
|
||||
});
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { FormControl } from './FormControl';
|
13
app/react/components/form-components/FormError.tsx
Normal file
13
app/react/components/form-components/FormError.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { FormSection } from './FormSection';
|
|
@ -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',
|
||||
};
|
|
@ -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();
|
||||
});
|
|
@ -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>;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { FormSectionTitle } from './FormSectionTitle';
|
32
app/react/components/form-components/Input/Input.stories.tsx
Normal file
32
app/react/components/form-components/Input/Input.stories.tsx
Normal 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,
|
||||
};
|
15
app/react/components/form-components/Input/Input.tsx
Normal file
15
app/react/components/form-components/Input/Input.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
31
app/react/components/form-components/Input/Select.tsx
Normal file
31
app/react/components/form-components/Input/Select.tsx
Normal 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>
|
||||
);
|
||||
}
|
15
app/react/components/form-components/Input/Textarea.tsx
Normal file
15
app/react/components/form-components/Input/Textarea.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
2
app/react/components/form-components/Input/index.ts
Normal file
2
app/react/components/form-components/Input/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { Input } from './Input';
|
||||
export { Select } from './Select';
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 '';
|
||||
}
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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>;
|
||||
}
|
20
app/react/components/form-components/InputGroup/index.ts
Normal file
20
app/react/components/form-components/InputGroup/index.ts
Normal 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 };
|
|
@ -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;
|
||||
}
|
||||
}
|
63
app/react/components/form-components/ReactSelect.module.css
Normal file
63
app/react/components/form-components/ReactSelect.module.css
Normal 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);
|
||||
}
|
41
app/react/components/form-components/ReactSelect.tsx
Normal file
41
app/react/components/form-components/ReactSelect.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
22
app/react/components/form-components/Slider/Slider.test.tsx
Normal file
22
app/react/components/form-components/Slider/Slider.test.tsx
Normal 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();
|
||||
});
|
42
app/react/components/form-components/Slider/Slider.tsx
Normal file
42
app/react/components/form-components/Slider/Slider.tsx
Normal 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();
|
||||
}
|
68
app/react/components/form-components/SwitchField/Switch.css
Normal file
68
app/react/components/form-components/SwitchField/Switch.css
Normal 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);
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.root {
|
||||
margin-bottom: 0;
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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();
|
||||
});
|
57
app/react/components/form-components/SwitchField/Switch.tsx
Normal file
57
app/react/components/form-components/SwitchField/Switch.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
padding: 0;
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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);
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { SwitchField } from './SwitchField';
|
Loading…
Add table
Add a link
Reference in a new issue