Generic select component

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9113
Co-authored-by: Vijay Prasanna <11921040+vijayprasanna13@users.noreply.github.com>
GitOrigin-RevId: 8c2896c873053571b17a016015b9834a619cef80
This commit is contained in:
Nicolas Inchauspe 2023-06-23 16:06:41 +02:00 committed by hasura-bot
parent aaa1877452
commit a7d106ec67
29 changed files with 4889 additions and 4503 deletions

View File

@ -0,0 +1,56 @@
import React from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import SearchableSelect from './SearchableSelect';
type Option = { label: string; value: string };
export default {
component: SearchableSelect,
} satisfies Meta<typeof SearchableSelect>;
export const Basic: StoryObj<typeof SearchableSelect> = {
name: '🧰 Basic',
render: args => {
const [value, setValue] = React.useState<Option | string | undefined>(
args.value
);
const handleOnChange = (value: any) => {
setValue(value);
args.onChange(value);
};
return (
<>
<SearchableSelect {...args} value={value} onChange={handleOnChange} />
<span>{JSON.stringify(value, null, '\t')}</span>
</>
);
},
args: {
onChange: action('onChange'),
createNewOption: action('createNewOption'),
onSearchValueChange: action('onSearchValueChange'),
filterOption: 'prefix',
placeholder: 'The select placeholder',
options: [
{
label: 'Group 1',
options: [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
],
},
{
label: 'Group 2',
options: [
{ label: 'Option 4', value: 'option4' },
{ label: 'Option 5', value: 'option5' },
{ label: 'Option 6', value: 'option6' },
],
},
],
value: { label: 'Option 2', value: 'option2' },
isCreatable: true,
},
};

View File

@ -1,17 +1,17 @@
import React, { ReactText, useState, useMemo, useEffect, useRef } from 'react';
import Select, {
import { ReactSelect } from './../../../new-components/Form';
import {
components,
createFilter,
OptionProps,
OptionTypeBase,
ValueType,
OnChangeValue,
} from 'react-select';
import { isArray, isObject } from '../utils/jsUtils';
const { Option } = components;
const CustomOption: React.FC<OptionProps<OptionTypeBase>> = props => {
const CustomOption: React.FC<OptionProps<any>> = props => {
return (
<div
title={props.data.description || ''}
@ -25,32 +25,34 @@ const CustomOption: React.FC<OptionProps<OptionTypeBase>> = props => {
type Option = { label: string; value: string };
export interface SearchableSelectProps {
options: OptionTypeBase | ReactText[];
onChange: (value: ValueType<OptionTypeBase> | string) => void;
options: any | ReactText[];
onChange: (value: OnChangeValue<any, boolean> | string) => void;
value?: Option | string;
bsClass?: string;
styleOverrides?: Record<PropertyKey, any>;
placeholder: string;
filterOption: 'prefix' | 'fulltext';
placeholder?: string;
filterOption?: 'prefix' | 'fulltext';
isCreatable?: boolean;
onSearchValueChange?: (v: string | undefined | null) => void;
createNewOption?: (v: string) => ValueType<OptionTypeBase>;
createNewOption?: (v: string) => OnChangeValue<any, boolean>;
}
const SearchableSelect: React.FC<SearchableSelectProps> = ({
options,
onChange,
value,
bsClass,
styleOverrides,
placeholder,
filterOption,
placeholder = 'Select...',
filterOption = 'prefix',
isCreatable,
createNewOption,
onSearchValueChange,
}) => {
const [searchValue, setSearchValue] = useState<string | null>(null);
const [localValue, setLocalValue] = useState<Option | null>(null);
const [isFocused, setIsFocused] = useState(false);
const selectedItem = useRef<ValueType<OptionTypeBase> | string>(null);
const selectedItem = useRef<OnChangeValue<any, boolean> | string>(null);
const inputValue = useMemo(() => {
// if input is not focused we don't want to show inputValue
@ -66,18 +68,48 @@ const SearchableSelect: React.FC<SearchableSelectProps> = ({
useEffect(() => {
if (onSearchValueChange) onSearchValueChange(searchValue);
}, [searchValue]);
useEffect(() => {
let tempValue: Option | null;
if (value === '') {
setLocalValue(null);
} else {
if (value && !isObject(value)) {
tempValue = { value: value as string, label: value as string };
} else {
tempValue = value as Option;
}
setLocalValue(tempValue);
}
}, [value]);
const onMenuClose = () => {
if (selectedItem.current) onChange(selectedItem.current);
else if (createNewOption) onChange(createNewOption(searchValue || ''));
else if (createNewOption && isCreatable)
onChange(createNewOption(searchValue || ''));
setIsFocused(false);
setSearchValue(null);
selectedItem.current = null;
};
const onSelect = (v: ValueType<OptionTypeBase> | string) => {
selectedItem.current = v;
};
const onSelect = React.useCallback(
(v: OnChangeValue<any, boolean>) => {
let tempValue: Option | null;
// Force update when field is cleared
if (v === null) {
onChange('');
}
if (v && !isObject(v)) {
tempValue = { value: v as string, label: v as string };
} else {
tempValue = v as Option;
}
setLocalValue(tempValue);
selectedItem.current = tempValue;
},
[selectedItem]
);
const onFocus = () => {
setIsFocused(true);
let ipValue;
@ -117,29 +149,20 @@ const SearchableSelect: React.FC<SearchableSelectProps> = ({
});
}
if (value && !isObject(value)) {
value = { value: value as string, label: value as string };
}
if (isCreatable && createNewOption) {
if (searchValue) {
options = [createNewOption(searchValue), ...(options as Option[])];
}
}
return (
<Select
<ReactSelect
isSearchable
isClearable={false}
blurInputOnSelect
components={{ Option: CustomOption }}
classNamePrefix={bsClass}
placeholder={placeholder}
options={options as Option[]}
onChange={onSelect}
value={value as Option}
value={localValue}
onFocus={onFocus}
onBlur={onMenuClose}
inputValue={inputValue}
defaultInputValue={inputValue}
onInputChange={(s: string) => setSearchValue(s)}
styles={customStyles}
filterOption={searchValue ? customFilter : null}

View File

@ -1,6 +1,5 @@
import React from 'react';
import AceEditor from 'react-ace';
import { OptionTypeBase } from 'react-select';
import { LearnMoreLink } from '../../../../new-components/LearnMoreLink';
import { getConfirmation } from '../../../Common/utils/jsUtils';
@ -222,7 +221,7 @@ const ComputedFieldsEditor: React.FC<ComputedFieldsEditorProps> = ({
};
const handleFnSchemaChange = (
selectedOption: string | OptionTypeBase | null | undefined
selectedOption: string | any | null | undefined
) => {
// fetch schema fn
@ -251,7 +250,7 @@ const ComputedFieldsEditor: React.FC<ComputedFieldsEditorProps> = ({
};
const handleFnNameChange = (
selectedOption: string | OptionTypeBase | null | undefined
selectedOption: string | any | null | undefined
) => {
const newState = [...stateComputedFields];

View File

@ -1,6 +1,6 @@
import React, { useEffect, useReducer, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import Select, { ValueType } from 'react-select';
import Select, { OnChangeValue } from 'react-select';
import { Button } from '../../../../new-components/Button';
import { Index, IndexType, Table } from '../../../../dataSources/types';
@ -112,9 +112,13 @@ interface CreateIndexProps {
label: string;
value: IndexType;
}>;
onChangeIndextypeSelect: (value: ValueType<IndexColumnsSelect>) => void;
onChangeIndextypeSelect: (
value: OnChangeValue<IndexColumnsSelect, boolean>
) => void;
updateIndexName: (name: string) => void;
onChangeIndexColumnsSelect: (value: ValueType<IndexColumnsSelect>) => void;
onChangeIndexColumnsSelect: (
value: OnChangeValue<IndexColumnsSelect, boolean>
) => void;
toggleIndexCheckboxState: (currentValue: boolean) => () => void;
}
@ -249,7 +253,9 @@ const FieldsEditor: React.FC<IndexFieldsEditorProps> = props => {
})
);
const onChangeIndexColumnsSelect = (value: ValueType<IndexColumnsSelect>) => {
const onChangeIndexColumnsSelect = (
value: OnChangeValue<IndexColumnsSelect, boolean>
) => {
if (value) {
indexStateDispatch({
type: 'Indexes/UPDATE_INDEX_COLUMNS',
@ -259,7 +265,9 @@ const FieldsEditor: React.FC<IndexFieldsEditorProps> = props => {
});
}
};
const onChangeIndextypeSelect = (value: ValueType<IndexColumnsSelect>) => {
const onChangeIndextypeSelect = (
value: OnChangeValue<IndexColumnsSelect, boolean>
) => {
if (value) {
indexStateDispatch({
type: 'Indexes/UPDATE_INDEX_TYPE',

View File

@ -46,7 +46,7 @@ export const ConnectPostgresModal = (props: ConnectPostgresModalProps) => {
return (
<Form
onSubmit={values => {
onSubmit={(values: z.infer<typeof schema>) => {
if (alreadyUseNames?.includes(values.name)) {
setError('name', {
type: 'manual',

View File

@ -1,8 +1,8 @@
import { SelectItem } from '../../../../../components/Common/SelectInputSplitField/SelectInputSplitField';
import { CreateBooleanMap } from '../../../../../components/Common/utils/tsUtils';
import {
GraphQLSanitizedInputField,
Select,
GraphQLSanitizedInputField,
} from '../../../../../new-components/Form';
import { LimitedFeatureWrapper } from '../../../../ConnectDBRedesign/components/LimitedFeatureWrapper/LimitedFeatureWrapper';
import { LogicalModel } from '../../../../hasura-metadata-types';

View File

@ -99,7 +99,7 @@ export const SourceSelect = ({
onBlur={onBlur}
value={options.find(c => isEqual(c.value, value))}
onChange={val => onChange((val as SourceOption).value)}
inputRef={ref}
ref={ref}
components={{ Option, SingleValue }}
options={options}
styles={selectStyles}

View File

@ -28,16 +28,13 @@ const items: SourceSelectorItem[] = [
},
];
const validationSchema = z.any({});
const validationSchema = z.object({
fromSource: z.any(),
});
export const Basic: StoryFn<typeof SourcePicker> = () => (
<SimpleForm schema={validationSchema} onSubmit={action('onSubmit')}>
<SourcePicker
name="from"
items={items}
label="My label"
onChange={action('onChange')}
/>
<SourcePicker name="from" items={items} label="My label" />
</SimpleForm>
);
@ -49,17 +46,16 @@ export const Preselected: StoryFn<typeof SourcePicker> = () => {
onSubmit={action('onSubmit')}
options={{
defaultValues: {
fromSource: defaultValue,
fromSource: defaultValue.value,
},
}}
>
<SourcePicker
name="fromSource"
items={items}
label={
<>
Label <FaArrowAltCircleRight className="fill-emerald-700 ml-1.5" />
</>
label="Label"
labelIcon={
<FaArrowAltCircleRight className="fill-emerald-700 ml-1.5" />
}
disabled
/>

View File

@ -1,18 +1,13 @@
import React, { ReactNode } from 'react';
import {
AdvancedSelectField,
AdvancedSelectProps,
SelectField,
SelectFieldProps,
} from '../../../../../new-components/Form';
import { mapItemsToSourceOptions } from './SourcePicker.utils';
import { SourceSelectorItem } from './SourcePicker.types';
type SourcePickerProps = {
type SourcePickerProps = Omit<SelectFieldProps, 'options'> & {
items: SourceSelectorItem[];
name: string;
label: string | ReactNode;
defaultValue?: SourceSelectorItem;
disabled?: boolean;
onChange?: AdvancedSelectProps['onChange'];
};
export const SourcePicker: React.VFC<SourcePickerProps> = ({
@ -20,23 +15,15 @@ export const SourcePicker: React.VFC<SourcePickerProps> = ({
label,
name,
disabled,
onChange,
}) => {
const sourceOptions = mapItemsToSourceOptions(items);
return (
<>
{!!label && (
<div className="flex items-center gap-2 text-muted mb-xs">
<label className="font-semibold flex items-center">{label}</label>
</div>
)}
<AdvancedSelectField
name={name}
options={sourceOptions}
disabled={disabled}
onChange={onChange}
/>
</>
<SelectField
label={label}
name={name}
options={sourceOptions}
disabled={disabled}
/>
);
};

View File

@ -16,22 +16,18 @@ export const Basic: StoryFn<typeof TablePicker> = () => (
<SimpleForm
schema={z.object({
fromSource: z.object({
value: z.object({
dataSourceName: z.string(),
table: z.unknown(),
}),
dataSourceName: z.string(),
table: z.unknown(),
}),
})}
onSubmit={action('onSubmit')}
options={{
defaultValues: {
fromSource: {
value: {
dataSourceName: 'bikes',
table: {
name: 'orders',
schema: 'sales',
},
dataSourceName: 'bikes',
table: {
name: 'orders',
schema: 'sales',
},
},
},

View File

@ -13,17 +13,6 @@ import {
mapMetadataSourceToSelectorItems,
} from './TablePicker.utils';
const getLabel = (type: TablePickerProps['type']) =>
type === 'fromSource' ? (
<>
From Source <FaArrowAltCircleRight className="fill-emerald-700 ml-1.5" />
</>
) : (
<>
To Reference <FaArrowAltCircleLeft className="fill-violet-700 ml-1.5" />
</>
);
export const TablePicker: React.VFC<TablePickerProps> = ({
type,
disabled = false,
@ -63,15 +52,20 @@ export const TablePicker: React.VFC<TablePickerProps> = ({
if (!metadataSources) return <Skeleton count={5} height={20} />;
const sourcePickerLabel = getLabel(type);
return (
<div className="h-full">
<div className="my-2">
<SourcePicker
name={type}
items={items}
label={sourcePickerLabel}
label={type === 'fromSource' ? 'From Source' : 'To Reference'}
labelIcon={
type === 'fromSource' ? (
<FaArrowAltCircleRight className="fill-emerald-700 ml-1.5" />
) : (
<FaArrowAltCircleLeft className="fill-violet-700 ml-1.5" />
)
}
disabled={disabled}
/>
</div>

View File

@ -2,7 +2,7 @@ import React from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
AdvancedSelectField,
SelectField,
CheckboxesField,
InputField,
} from '../../../../../new-components/Form';
@ -307,7 +307,7 @@ export const RegistrationForm: React.FC<Props> = (props: Props) => {
name="ee-registration-form-hasura-use-case"
passHtmlAttributesToChildren
>
<AdvancedSelectField
<SelectField
name="hasuraUseCase"
options={[
{ value: 'data-api', label: 'Data API on my databases' },

View File

@ -29,12 +29,7 @@ export const registrationSchema = z.object({
value => value === true,
'Please agree to our Terms of Service and Privacy Policy'
),
hasuraUseCase: z
.object({
label: z.string(),
value: z.string(),
})
.transform(val => val.value),
hasuraUseCase: z.string(),
eeUseCase: z.array(z.string()).nonempty({
message: 'Please select at least one use case',
}),

View File

@ -16,6 +16,7 @@ import { formSchema } from './schema';
import { Toggle } from './components/Toggle';
import { useResetDefaultFormValues } from './hooks/useResetDefaultFormValues';
import { CollapsibleFieldWrapper } from './components/CollapsibleFieldWrapper';
import { z } from 'zod';
interface FormProps {
skeletonMode: boolean;
@ -47,7 +48,7 @@ export function Form(props: FormProps) {
return (
<ConsoleForm
onSubmit={data => {
onSubmit={(data: z.infer<typeof formSchema>) => {
onSubmit(data);
}}
>

View File

@ -1,195 +0,0 @@
import React from 'react';
import { FaPlug, FaTable } from 'react-icons/fa';
import { action } from '@storybook/addon-actions';
import { StoryObj, Meta } from '@storybook/react';
import { expect } from '@storybook/jest';
import { userEvent } from '@storybook/testing-library';
import { within } from '@storybook/testing-library';
import { AdvancedSelect } from '.';
export default {
title: 'components/Forms 📁/Advanced Select 🧬',
component: AdvancedSelect,
parameters: {
docs: {
source: { type: 'code' },
},
},
} as Meta<typeof AdvancedSelect>;
export const Basic: StoryObj<typeof AdvancedSelect> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', disabled: true },
{ value: 'value2', label: 'Value 2' },
];
return (
<AdvancedSelect
name="selectNames"
label="The select label"
options={options}
onChange={action('onChange')}
onBlur={action('onBlur')}
/>
);
},
name: '🧰 Basic',
parameters: {
docs: {
source: { state: 'open' },
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Open select
await userEvent.click(await canvas.findByText('Select...'));
// Select first element
await userEvent.click(await canvas.findByText('Value 0'));
// Verify placeholder is gone and it's replaced by selected value
expect(canvas.queryByText('Select...')).not.toBeInTheDocument();
expect(canvas.getByText('Value 0')).toBeInTheDocument();
// Open select
await userEvent.click(await canvas.findByText('Value 0'));
// Select non disabled element
await userEvent.click(await canvas.findByText('Value 2'));
// New element is selected
expect(await canvas.getByText('Value 2')).toBeInTheDocument();
// Open select
await userEvent.click(await canvas.findByText('Value 2'));
// Click on disabled element
await userEvent.click(await canvas.findByText('Value 1'));
// Click on canvas to close select
await userEvent.click(canvasElement);
// Verify disabled element is not selected
expect(await canvas.queryByText('Value 1')).not.toBeInTheDocument();
// Verify 'Value 2' is still selected
expect(await canvas.getByText('Value 2')).toBeInTheDocument();
},
};
export const Multi: StoryObj<typeof AdvancedSelect> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', disabled: true },
{ value: 'value2', label: 'Value 2' },
];
return (
<AdvancedSelect
name="selectNames"
label="The select label"
options={options}
isMulti
onChange={action('onChange')}
onBlur={action('onBlur')}
/>
);
},
name: '🎭 Variant - Multi selection',
parameters: {
docs: {
source: { state: 'open' },
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Open select
await userEvent.click(await canvas.findByText('Select...'));
// Select first element
await userEvent.click(await canvas.findByText('Value 0'));
// Verify placeholder is gone and it's replaced by selected value
expect(canvas.queryByText('Select...')).not.toBeInTheDocument();
expect(canvas.getByText('Value 0')).toBeInTheDocument();
// Open select
await userEvent.click(await canvas.findByText('Value 0'));
// Select non disabled element
await userEvent.click(await canvas.findByText('Value 2'));
// Verify both elements are selected
expect(canvas.getByText('Value 0')).toBeInTheDocument();
expect(canvas.getByText('Value 2')).toBeInTheDocument();
// Open select
await userEvent.click(await canvas.findByText('Value 0'));
// Click on disabled element
await userEvent.click(await canvas.findByText('Value 1'));
// Click on canvas to close select
await userEvent.click(canvasElement);
// Verify disabled element is not selected
expect(canvas.queryByText('Value 1')).not.toBeInTheDocument();
},
};
export const TableSelection: StoryObj<typeof AdvancedSelect> = {
render: () => {
const options = [
{
value: 'a_database.public.users',
label: (
<>
<FaTable fill="fill-slate-900" />
<span className="text-slate-500 ml-1.5">a_database / public /</span>
<span className="text-slate-900 ml-1">users</span>
</>
),
},
{
value: 'user_schema.users',
label: (
<>
<FaPlug fill="fill-slate-900" />
<span className="text-slate-900 ml-1.5">user_schema /</span>
<span className="text-muted ml-1">users</span>
</>
),
},
];
return (
<AdvancedSelect
name="selectTable"
label="From source"
options={options}
placeholder="Select a reference..."
onChange={action('onChange')}
onBlur={action('onBlur')}
/>
);
},
name: '📄 Use Case - Table Selection',
parameters: {
docs: {
source: { state: 'open' },
},
},
};

View File

@ -1,235 +0,0 @@
import React, { ComponentProps, FocusEventHandler, ReactElement } from 'react';
import clsx from 'clsx';
import {
ControllerRenderProps,
FieldValues,
RefCallBack,
} from 'react-hook-form';
import Select, {
Props as SelectProps,
OptionsType,
OptionTypeBase,
components,
} from 'react-select';
import { FiX, FiChevronDown } from 'react-icons/fi';
import { Tooltip } from '../Tooltip';
import { FieldWrapperPassThroughProps } from './FieldWrapper';
export type MultiSelectItem = {
label: string | ReactElement;
value: any;
disabled?: boolean;
};
export type AdvancedSelectProps = FieldWrapperPassThroughProps & {
/**
* The field name
*/
name: string;
/**
* The options to display in the select
*/
options: MultiSelectItem[];
/**
* The placeholder text to display when the select is not valued
*/
placeholder?: string;
/**
* Flag to indicate if the select is disabled
*/
disabled?: boolean;
/**
* The default value
*/
defaultValue?: MultiSelectItem | null;
/**
* The value
*/
value?: string;
/**
* Flag to indicate if the dropdown is multi values
*/
isMulti?: boolean;
/**
* On blur callback
*/
onChange?: SelectProps<OptionTypeBase>['onChange'];
/**
* On blur callback
*/
onBlur?: FocusEventHandler;
/**
* Prop to be used with react-hook-form
*/
field?: ControllerRenderProps<FieldValues, string>;
/**
* Useful to be used with react-hook-form
*/
ref?: RefCallBack;
};
const customComponents: ComponentProps<typeof Select>['components'] = {
DropdownIndicator: props => {
const { className } = props;
return (
<components.DropdownIndicator
{...props}
className={clsx(className, 'text-gray-500 hover:text-gray-500')}
>
<FiChevronDown />
</components.DropdownIndicator>
);
},
Option: props => {
return (
<components.Option
{...props}
className={clsx(
props.className,
'flex items-center px-xs py-xs rounded whitespace-nowrap',
props.isDisabled
? 'cursor-not-allowed text-gray-200'
: 'cursor-pointer hover:bg-gray-100',
props.isFocused && props.isDisabled ? 'bg-transparent' : '',
props.isFocused && !props.isDisabled ? 'bg-slate-200' : '',
props.isSelected && 'bg-slate-200 text-slate-800'
)}
/>
);
},
ValueContainer: props => {
const { className } = props;
return (
<components.ValueContainer
{...props}
className={clsx(className, 'p-0')}
/>
);
},
Control: props => {
const { className } = props;
return (
<components.Control
{...props}
className={clsx(
className,
'flex h-full items-center justify-between px-2 rounded border border-gray-300 hover:border-gray-400',
props.isFocused && 'ring-2 ring-yellow-200 border-yellow-400'
)}
/>
);
},
ClearIndicator: props => {
const { className } = props;
return (
<components.ClearIndicator
{...props}
className={clsx(className, 'text-gray-500')}
>
<FiX />
</components.ClearIndicator>
);
},
MenuList: props => {
const { className } = props;
return (
<components.MenuList {...props} className={clsx(className, 'px-1')} />
);
},
MultiValueContainer: props => {
const { className } = props;
return (
<components.MultiValueContainer
{...props}
className={clsx(className, 'bg-gray-200 m-0')}
/>
);
},
IndicatorSeparator: () => null,
MultiValueLabel: props => {
const { className, children, ...rest } = props;
// Display a tooltip if the label is too long
const Wrapper = children.length > 20 ? Tooltip : React.Fragment;
return (
<Wrapper tooltipContentChildren={children}>
<components.MultiValueLabel
{...rest}
className={clsx(className, 'text-black')}
>
{children}
</components.MultiValueLabel>
</Wrapper>
);
},
};
export const AdvancedSelect: React.VFC<AdvancedSelectProps> = ({
name,
ref,
options,
placeholder,
dataTest,
disabled = false,
value,
defaultValue,
isMulti = false,
field,
onBlur,
onChange,
}) => {
// Convert the options to the format that react-select expects
const selectOptions: OptionsType<OptionTypeBase> = options.map(option => {
return {
value: option.value,
label: option.label,
isDisabled: option.disabled,
};
});
return (
<Select
{...field}
ref={ref}
name={name}
onBlur={onBlur}
onChange={(_value, action) => {
if (field?.onChange) {
field?.onChange(_value, action);
}
if (onChange) {
onChange(_value, action);
}
}}
defaultValue={defaultValue}
id={name}
isMulti={isMulti}
className={clsx(
'block w-full h-input shadow-sm rounded border-gray-300 hover:border-gray-400 focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-yellow-200 focus-visible:border-yellow-400',
value?.length === 0 ? 'text-black' : 'text-gray-500',
disabled
? 'cursor-not-allowed bg-slate-200 border-slate-200 hover:border-gray-200'
: 'hover:border-slate-400'
)}
components={customComponents}
classNamePrefix="select"
data-test={dataTest}
data-testid={name}
options={selectOptions}
isDisabled={disabled}
styles={{
multiValueLabel: baseStyles => ({
...baseStyles,
maxWidth: 100,
}),
}}
placeholder={placeholder}
theme={theme => ({
...theme,
spacing: {
...theme.spacing,
controlHeight: 26, // h-input - py-xs
},
})}
/>
);
};

View File

@ -1,351 +0,0 @@
import React from 'react';
import { StoryObj, Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { z } from 'zod';
import { AdvancedSelectField, SimpleForm, useConsoleForm } from '.';
import { screen, userEvent, within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
export default {
title: 'components/Forms 📁/Advanced Select Field 🧬',
component: AdvancedSelectField,
parameters: {
docs: {
source: { type: 'code' },
},
},
} as Meta<typeof AdvancedSelectField>;
export const Basic: StoryObj<typeof AdvancedSelectField> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', disabled: true },
{ value: 'value2', label: 'Value 2' },
];
const validationSchema = z.object({});
return (
<SimpleForm schema={validationSchema} onSubmit={action('onSubmit')}>
<AdvancedSelectField
name="selectNames"
label="The select label"
options={options}
/>
</SimpleForm>
);
},
name: '🧰 Basic',
parameters: {
docs: {
source: { state: 'open' },
},
},
};
export const VariantWithDescription: StoryObj<typeof AdvancedSelectField> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', disabled: true },
{ value: 'value2', label: 'Value 2' },
];
const validationSchema = z.object({});
return (
<SimpleForm schema={validationSchema} onSubmit={action('onSubmit')}>
<AdvancedSelectField
name="selectNames"
label="The select label"
description="Select description"
options={options}
/>
</SimpleForm>
);
},
name: '🎭 Variant - With description',
parameters: {
docs: {
source: { state: 'open' },
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Verify description is present
expect(canvas.getByText('Select description')).toBeInTheDocument();
},
};
export const VariantWithTooltip: StoryObj<typeof AdvancedSelectField> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', disabled: true },
{ value: 'value2', label: 'Value 2' },
];
const validationSchema = z.object({});
return (
<SimpleForm schema={validationSchema} onSubmit={action('onSubmit')}>
<AdvancedSelectField
name="selectNames"
label="The select label"
tooltip="Select tooltip"
options={options}
/>
</SimpleForm>
);
},
name: '🎭 Variant - With tooltip',
parameters: {
// The visual screenshot is not stable. Sometimes the tooltip is slightly to the right, sometimes
// not. This is not good and would require more investigation (is it a tooltip problem? Is is a
// parent problem? A CSS conflict? Does it happens only in Storybook?) but it's a blocker for the
// current PR (getting Chromatic a required step for merging) and we need to fix it quick.
// Ignoring the element through data-chromatic="ignore" could now be the right solution right now
// since the tooltip DOM element is outside of the parent.
chromatic: { disableSnapshot: true },
docs: {
source: { state: 'open' },
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Hover on tooltip
await userEvent.hover(await canvas.findByTestId('tooltip-trigger'));
// Verify tooltip is present
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
},
};
export const StateWithDefaultValue: StoryObj<typeof AdvancedSelectField> = {
render: () => {
const options = [
{ value: { name: 'value0' }, label: 'Value 0' },
{ value: { name: 'value1' }, label: 'Value 1', disabled: true },
{ value: { name: 'value2' }, label: 'Value 2' },
];
const defaultValues = {
selectNames: [{ value: { name: 'value2' }, label: 'Value 2' }],
};
const validationSchema = z.object({});
return (
<SimpleForm
schema={validationSchema}
options={{ defaultValues }}
onSubmit={action('onSubmit')}
>
<AdvancedSelectField
name="selectNames"
label="The select label"
options={options}
/>
</SimpleForm>
);
},
name: '🔁 State - With default value',
parameters: {
docs: {
description: {
story: `Use \`<SimpleForm>\` options to set default value.`,
},
source: { state: 'open' },
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Verify default value is selected
expect(canvas.getByText('Value 2')).toBeInTheDocument();
},
};
export const StateLoading: StoryObj<typeof AdvancedSelectField> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', loading: true },
{ value: 'value2', label: 'Value 2' },
];
const validationSchema = z.object({});
return (
<SimpleForm schema={validationSchema} onSubmit={action('onSubmit')}>
<AdvancedSelectField
name="selectNames"
label="The select label"
options={options}
loading
/>
</SimpleForm>
);
},
name: '🔁 State - Loading',
parameters: {
docs: {
source: { state: 'open' },
},
},
};
export const StateDisabled: StoryObj<typeof AdvancedSelectField> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', disabled: true },
{ value: 'value2', label: 'Value 2' },
];
const validationSchema = z.object({});
return (
<SimpleForm schema={validationSchema} onSubmit={action('onSubmit')}>
<AdvancedSelectField
name="selectNames"
label="The select label"
options={options}
disabled
/>
</SimpleForm>
);
},
name: '🔁 State - Disabled',
parameters: {
docs: {
source: { state: 'open' },
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const disabledSelect = await canvas.findByText('Select...');
// Verify select has pointer-events: none
expect(disabledSelect).toHaveStyle('pointer-events: none');
},
};
export const StateWithErrorMessage: StoryObj<typeof AdvancedSelectField> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', disabled: true },
{ value: 'value2', label: 'Value 2' },
];
const schema = z.object({
selectNames: z
.array(
z.object({
value: z.enum(['value0', 'value2']),
label: z.string(),
disabled: z.boolean().optional(),
})
)
.min(1),
});
const {
methods: { trigger },
Form,
} = useConsoleForm({
schema,
});
React.useEffect(() => {
// Use useEffect hook to wait for the form to be rendered before triggering validation
void trigger('selectNames');
}, []);
return (
<Form onSubmit={action('onSubmit')}>
<AdvancedSelectField
name="selectNames"
label="The select label"
options={options}
/>
</Form>
);
},
name: '🔁 State - With error message',
parameters: {
docs: {
description: {
story: `Incorrect value is set then \`<SimpleForm>\` validation is automatically triggered.`,
},
source: { state: 'open' },
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Verify error message is displayed
expect(await canvas.findByText('Required')).toBeInTheDocument();
},
};
export const TestingScalability: StoryObj<typeof AdvancedSelectField> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', disabled: true },
{ value: 'value2', label: 'Value 2' },
{
value: 'value3',
label:
'Value 4 - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
},
];
const validationSchema = z.object({});
return (
<SimpleForm schema={validationSchema} onSubmit={action('onSubmit')}>
<AdvancedSelectField
name="selectNames"
label="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
tooltip="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
options={options}
/>
</SimpleForm>
);
},
name: '🧪 Testing - Scalability',
parameters: {
docs: {
source: { state: 'open' },
},
},
};

View File

@ -1,52 +0,0 @@
import React from 'react';
import { Controller, FieldError, useFormContext } from 'react-hook-form';
import get from 'lodash/get';
import { FieldWrapper } from './FieldWrapper';
import { AdvancedSelect, AdvancedSelectProps } from './AdvancedSelect';
export const AdvancedSelectField: React.VFC<AdvancedSelectProps> = ({
dataTest,
disabled = false,
isMulti = false,
name,
options,
placeholder,
value,
...wrapperProps
}) => {
const {
register,
formState: { errors },
watch,
control,
} = useFormContext();
const maybeError: FieldError | undefined = get(errors, name);
const { onBlur, ref } = register(name);
const watchValue = watch(name);
return (
<FieldWrapper id={name} {...wrapperProps} error={maybeError}>
{/* Based on https://react-hook-form.com/get-started/#IntegratingwithUIlibraries */}
<Controller
name={name}
control={control}
render={({ field }) => (
<AdvancedSelect
dataTest={dataTest}
disabled={disabled}
field={field}
isMulti={isMulti}
name={name}
onBlur={onBlur}
options={options}
placeholder={placeholder}
ref={ref}
value={value || watchValue}
/>
)}
/>
</FieldWrapper>
);
};

View File

@ -0,0 +1,283 @@
import { expect } from '@storybook/jest';
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { ReactSelect } from './ReactSelect';
import { action } from '@storybook/addon-actions';
import { FaCandyCane, FaPlug, FaTable } from 'react-icons/fa';
import { userEvent, within } from '@storybook/testing-library';
export default {
title: 'components/Forms 📁/Select ⚛️',
component: ReactSelect,
} satisfies Meta<typeof ReactSelect>;
const FLAVOURS = [
{ value: 'chocolate', label: 'Chocolate' },
{ value: 'strawberry', label: 'Strawberry', isDisabled: true },
{ value: 'vanilla', label: 'Vanilla' },
];
const DATABASES = [
{
value: 'a_database.public.users',
icon: FaTable,
label: 'a_database / public / users',
},
{
value: 'user_schema.users',
icon: FaPlug,
label: 'user_schema / users',
},
];
export const Basic: StoryObj<typeof ReactSelect> = {
name: '🧰 Basic',
args: {
options: [...FLAVOURS, ...DATABASES],
onChange: action('onChange'),
classNamePrefix: 'react-select',
placeholder: 'Select an option...',
noOptionsMessage: () => 'No matching options',
},
};
export const StateWithDefaultValue: StoryObj<typeof ReactSelect> = {
...Basic,
name: '🔁 State - With default value',
args: {
...Basic.args,
defaultValue: { value: 'vanilla', label: 'Vanilla' },
},
};
export const StateWithFancyDefaultValue: StoryObj<typeof ReactSelect> = {
...Basic,
name: '🔁 State - With fancy default value',
args: {
...Basic.args,
defaultValue: {
value: 'user_schema.users',
icon: FaPlug,
label: 'user_schema / users',
},
},
};
export const StateDisabled: StoryObj<typeof ReactSelect> = {
...Basic,
name: '🔁 State - Disabled',
args: {
...Basic.args,
isDisabled: true,
},
};
export const StateInvalid: StoryObj<typeof ReactSelect> = {
...Basic,
name: '🔁 State - Invalid',
args: {
...Basic.args,
isInvalid: true,
},
};
export const VariantMulti: StoryObj<typeof ReactSelect> = {
...Basic,
name: '🎭 Variant - Multi',
args: {
...Basic.args,
isMulti: true,
closeMenuOnSelect: false,
},
};
export const VariantMultiWithDefaultValues: StoryObj<typeof ReactSelect> = {
...Basic,
name: '🎭 Variant - Multi with default values',
args: {
...Basic.args,
isMulti: true,
closeMenuOnSelect: false,
defaultValue: [
{ value: 'chocolate', label: 'Chocolate' },
{ value: 'strawberry', label: 'Strawberry' },
],
},
};
export const VariantMultiWithDefaultFancyValues: StoryObj<typeof ReactSelect> =
{
...Basic,
name: '🎭 Variant - Multi with default fancy values',
args: {
...Basic.args,
isMulti: true,
closeMenuOnSelect: false,
defaultValue: [
{ value: 'chocolate', label: 'Chocolate' },
{ value: 'strawberry', label: 'Strawberry' },
{
value: 'user_schema.users',
icon: FaPlug,
label: 'user_schema / users',
},
],
},
};
export const TestSingleValue: StoryObj<typeof ReactSelect> = {
...Basic,
name: '🧪 Test - Single value',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Open select
await userEvent.click(await canvas.findByText('Select an option...'));
// Select first element
await userEvent.click(await canvas.findByText('Chocolate'), undefined, {
skipHover: true,
});
// Verify placeholder is gone and it's replaced by selected value
expect(canvas.queryByText('Select an option...')).not.toBeInTheDocument();
await expect(canvas.getByText('Chocolate')).toBeInTheDocument();
// Open select
await userEvent.click(await canvas.findByText('Chocolate'));
// Select non disabled element
await userEvent.click(await canvas.findByText('Vanilla'), undefined, {
skipHover: true,
});
// New element is selected
await expect(await canvas.getByText('Vanilla')).toBeInTheDocument();
// Open select
await userEvent.click(await canvas.findByText('Vanilla'), undefined, {
skipHover: true,
});
// Click on disabled element
await userEvent.click(await canvas.findByText('Strawberry'), undefined, {
skipHover: true,
});
// Click on canvas to close select
await userEvent.click(canvasElement);
// Verify disabled element is not selected
expect(await canvas.queryByText('Strawberry')).not.toBeInTheDocument();
// Verify 'Vanilla' is still selected
await expect(await canvas.getByText('Vanilla')).toBeInTheDocument();
// Remove selected value
await userEvent.click(
canvasElement?.getElementsByClassName(
'react-select__clear-indicator'
)[0] as HTMLElement
);
// Verify 'Vanilla' has been removed
await expect(await canvas.queryByText('Vanilla')).not.toBeInTheDocument();
},
};
export const TestMultiValue: StoryObj<typeof ReactSelect> = {
...Basic,
name: '🧪 Test - Multi value',
args: {
...Basic.args,
isMulti: true,
closeMenuOnSelect: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Open select
await userEvent.click(await canvas.findByText('Select an option...'));
// Select first element
await userEvent.click(await canvas.findByText('Chocolate'), undefined, {
skipHover: true,
});
// Verify placeholder is gone and it's replaced by selected value
expect(canvas.queryByText('Select an option...')).not.toBeInTheDocument();
await expect(await canvas.findByText('Chocolate')).toBeInTheDocument();
// Select non disabled element
await userEvent.click(await canvas.findByText('Vanilla'), undefined, {
skipHover: true,
});
// Verify both elements are selected
await expect(await canvas.findByText('Vanilla')).toBeInTheDocument();
await expect(await canvas.findByText('Chocolate')).toBeInTheDocument();
// Click on disabled element
await userEvent.click(await canvas.findByText('Strawberry'), undefined, {
skipHover: true,
});
// Click on canvas to close select
await userEvent.click(canvasElement);
// Verify disabled element is not selected
expect(canvas.queryByText('Strawberry')).not.toBeInTheDocument();
// Remove 'Chocolate' value
await userEvent.click(
canvasElement?.getElementsByClassName(
'react-select__multi-value__remove'
)[0] as HTMLElement
);
// Verify 'Chocolate' has been removed
await expect(await canvas.queryByText('Chocolate')).not.toBeInTheDocument();
await expect(await canvas.queryByText('Vanilla')).toBeInTheDocument();
// Remove selected value
await userEvent.click(
canvasElement?.getElementsByClassName(
'react-select__clear-indicator'
)[0] as HTMLElement
);
// Verify 'Vanilla' has been removed
await expect(await canvas.queryByText('Vanilla')).not.toBeInTheDocument();
},
};
export const VariantGrouped: StoryObj<typeof ReactSelect> = {
...Basic,
name: '🎭 Variant - Grouped',
args: {
...Basic.args,
options: [
{
label: 'Icecream',
options: FLAVOURS,
},
{
label: 'Databases',
options: DATABASES,
},
],
},
};
export const Test1000Options: StoryObj<typeof ReactSelect> = {
...Basic,
name: '🧪 Test - 10000 options',
args: {
...Basic.args,
options: Array.from({ length: 10000 }, (_, i) => ({
icon: FaCandyCane,
label: `Option ${i} label`,
value: `option${i}-value`,
})),
},
};

View File

@ -0,0 +1,207 @@
import clsx from 'clsx';
import React from 'react';
import { IconType } from 'react-icons';
import {
default as ReactSelectOriginal,
GroupBase,
Props as ReactSelectPropsOriginal,
MenuListProps,
} from 'react-select';
import { VariableSizeList, areEqual } from 'react-window';
export type ReactSelectOptionType = {
icon?: IconType;
label: string;
value: any;
isDisabled?: boolean;
};
export type ReactSelectProps<
Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
> = ReactSelectPropsOriginal<Option, IsMulti, Group> & {
isInvalid?: boolean;
};
export const ReactSelect = <IsMulti extends boolean = false>({
isSearchable = true,
isClearable = true,
isInvalid,
isDisabled,
...props
}: ReactSelectProps<
ReactSelectOptionType,
IsMulti,
GroupBase<ReactSelectOptionType>
>) => {
const formatOptionLabel = ({ label, icon }: ReactSelectOptionType) => (
<div>
{icon && (
<span className="mr-2 -translate-y-0.5 inline-block">
{React.createElement(icon)}
</span>
)}
<span>{label}</span>
</div>
);
const MenuList = (
props: MenuListProps<
ReactSelectOptionType,
IsMulti,
GroupBase<ReactSelectOptionType>
>
) => {
const { children, maxHeight = '300px' } = props;
const [childrenArray, setChildrenArray] = React.useState<
React.ReactElement[]
>([]);
React.useEffect(() => {
const childrenArray = React.Children.toArray(
React.Children.toArray(children)
) as React.ReactElement[];
const flatChildrenArray: React.ReactElement[] = [];
childrenArray.forEach((child: React.ReactElement) => {
if (child?.key?.toString().includes('group')) {
flatChildrenArray.push({
...child,
props: { ...child.props, children: undefined },
});
if (child?.props?.children) {
flatChildrenArray.push(...child.props.children);
}
} else {
flatChildrenArray.push(child);
}
});
setChildrenArray(flatChildrenArray);
}, [children]);
const Row = React.memo(
({ index, style }: { index: number; style: React.CSSProperties }) => {
return <div style={style}>{childrenArray[index]}</div>;
},
areEqual
);
return (
<VariableSizeList
height={maxHeight}
itemCount={childrenArray.length}
itemSize={index =>
childrenArray[index].key?.toString().includes('group') ? 24 : 37
}
width="100%"
initialScrollOffset={0}
style={{ maxHeight: '300px', height: 'min-content' }}
>
{Row}
</VariableSizeList>
);
};
return (
<ReactSelectOriginal
{...props}
components={{ MenuList }}
unstyled
isSearchable={isSearchable}
isClearable={isClearable}
formatOptionLabel={formatOptionLabel}
classNames={{
container: () =>
clsx(
'block',
'w-full',
'h-input',
'shadow-sm',
'rounded',
'bg-white',
'border',
'hover:border-slate-400',
'focus-within:outline-0',
'focus-within:ring-2',
'focus-within:ring-yellow-200',
'focus-within:border-yellow-400',
isInvalid
? clsx(
'border-red-600',
'hover:border-red-600',
'focus-within:border-red-600',
'focus-within:hover:border-red-600'
)
: 'focus-within:border-yellow-400 border-slate-300 focus-within:hover:border-yellow-400',
isDisabled
? 'pointer-events-none bg-slate-200 border-slate-200 hover:border-slate-200'
: ''
),
control: () => clsx('!min-h-0'),
valueContainer: state =>
clsx(
'px-[10.5px]',
'py-[6px]',
state.hasValue ? 'text-black' : 'text-slate-500',
isDisabled ? 'pointer-events-none' : ''
),
multiValue: () =>
clsx(
'bg-slate-200',
'text-slate-800',
'rounded',
'gap-2',
'px-2',
'mr-1'
),
menu: () =>
clsx(
'mt-2',
'rounded',
'shadow-md',
'bg-white',
'ring-1',
'ring-slate-300',
'divide-y',
'divide-slate-300',
'focus:outline-none',
'overflow-hidden'
),
groupHeading: () =>
clsx('text-slate-500 text-sm bg-slate-100', 'py-1', 'px-2'),
option: state =>
clsx(
'relative',
'cursor-pointer',
'flex',
'items-center',
'px-xs',
'py-xs',
'rounded',
'whitespace-nowrap',
state.isFocused ? 'bg-amber-50' : '',
state.isDisabled ? 'opacity-40 italic' : '',
state.isSelected
? clsx(
'pl-5',
'before:absolute',
'before:inset-0',
'before:top-1/2',
'before:-translate-y-1/2',
'before:h-4',
'before:border-transparent',
'before:border-l-slate-300',
'before:border-8'
)
: ''
),
noOptionsMessage: () => clsx('text-slate-500 p-4'),
indicatorsContainer: () =>
clsx('text-slate-500', 'flex', 'justify-center', 'px-3', 'gap-3'),
indicatorSeparator: () => clsx('bg-slate-300', 'my-1', 'w-px'),
}}
/>
);
};

View File

@ -1,12 +1,45 @@
import React from 'react';
import { StoryObj, Meta } from '@storybook/react';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { z } from 'zod';
import { SimpleForm, Select, useConsoleForm } from '.';
const formDecorator =
({ triggerValidation = false, withDefaultValue = false } = {}) =>
(Story: React.FC) => {
const schema = z.object({
selectNames: z
.enum(['value0', 'value1', 'value2'])
.refine(val => val !== 'value2', {
message: 'Value2 not suitable for this case',
}),
});
const defaultValues: z.infer<typeof schema> = { selectNames: 'value2' };
const {
methods: { trigger },
Form,
} = useConsoleForm({
schema,
...(withDefaultValue ? { options: { defaultValues } } : {}),
});
React.useEffect(() => {
if (triggerValidation) {
void trigger();
}
}, []);
return (
<Form onSubmit={action('onSubmit')}>
<Story />
</Form>
);
};
export default {
title: 'components/Forms 📁/Select 🧬',
title: 'components/Forms 📁/SimpleSelectField 🧬',
component: Select,
parameters: {
docs: {
@ -18,21 +51,10 @@ Default CSS display is \`block\`, provided without padding and margin (displayed
source: { type: 'code' },
},
},
} as Meta<typeof Select>;
} satisfies Meta<typeof Select>;
export const ApiPlayground: StoryObj<typeof Select> = {
render: args => {
const validationSchema = z.object({});
return (
<SimpleForm schema={validationSchema} onSubmit={action('onSubmit')}>
<Select {...args} />
</SimpleForm>
);
},
name: '⚙️ API',
args: {
name: 'selectNames',
label: 'Play with me!',
@ -42,236 +64,74 @@ export const ApiPlayground: StoryObj<typeof Select> = {
{ value: 'value2', label: 'Value 2' },
],
},
decorators: [formDecorator()],
};
export const Basic: StoryObj<typeof Select> = {
render: () => {
const options = [
name: '🧰 Basic',
args: {
name: 'selectNames',
label: 'Play with me!',
options: [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', disabled: true },
{ value: 'value2', label: 'Value 2' },
];
const validationSchema = z.object({});
return (
<SimpleForm schema={validationSchema} onSubmit={action('onSubmit')}>
<Select name="selectNames" label="The select label" options={options} />
</SimpleForm>
);
},
name: '🧰 Basic',
parameters: {
docs: {
source: { state: 'open' },
},
],
},
decorators: [formDecorator()],
};
export const VariantWithDescription: StoryObj<typeof Select> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', disabled: true },
{ value: 'value2', label: 'Value 2' },
];
const validationSchema = z.object({});
return (
<SimpleForm schema={validationSchema} onSubmit={action('onSubmit')}>
<Select
name="selectNames"
label="The select label"
description="Select description"
options={options}
/>
</SimpleForm>
);
},
...Basic,
name: '🎭 Variant - With description',
parameters: {
docs: {
source: { state: 'open' },
},
args: {
...Basic.args,
description: 'SimpleSelectField description',
},
};
export const VariantWithTooltip: StoryObj<typeof Select> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', disabled: true },
{ value: 'value2', label: 'Value 2' },
];
const validationSchema = z.object({});
return (
<SimpleForm schema={validationSchema} onSubmit={action('onSubmit')}>
<Select
name="selectNames"
label="The select label"
tooltip="Select tooltip"
options={options}
/>
</SimpleForm>
);
},
...Basic,
name: '🎭 Variant - With tooltip',
parameters: {
docs: {
source: { state: 'open' },
},
args: {
...Basic.args,
tooltip: 'SimpleSelectField tooltip',
},
};
export const StateWithDefaultValue: StoryObj<typeof Select> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', disabled: true },
{ value: 'value2', label: 'Value 2' },
];
const defaultValues = { selectNames: 'value2' };
const validationSchema = z.object({});
return (
<SimpleForm
schema={validationSchema}
options={{ defaultValues }}
onSubmit={action('onSubmit')}
>
<Select name="selectNames" label="The select label" options={options} />
</SimpleForm>
);
},
...Basic,
name: '🔁 State - With default value',
parameters: {
docs: {
description: {
story: `Use \`<SimpleForm>\` options to set default value.`,
},
source: { state: 'open' },
},
args: {
...Basic.args,
},
decorators: [formDecorator({ withDefaultValue: true })],
};
export const StateLoading: StoryObj<typeof Select> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', loading: true },
{ value: 'value2', label: 'Value 2' },
];
const validationSchema = z.object({});
return (
<SimpleForm schema={validationSchema} onSubmit={action('onSubmit')}>
{() => (
<Select
name="selectNames"
label="The select label"
options={options}
loading
/>
)}
</SimpleForm>
);
},
...Basic,
name: '🔁 State - Loading',
parameters: {
docs: {
source: { state: 'open' },
},
args: {
...Basic.args,
loading: true,
},
};
export const StateDisabled: StoryObj<typeof Select> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', disabled: true },
{ value: 'value2', label: 'Value 2' },
];
const validationSchema = z.object({});
return (
<SimpleForm schema={validationSchema} onSubmit={action('onSubmit')}>
<Select
name="selectNames"
label="The select label"
options={options}
disabled
/>
</SimpleForm>
);
},
...Basic,
name: '🔁 State - Disabled',
parameters: {
docs: {
source: { state: 'open' },
},
args: {
...Basic.args,
disabled: true,
},
};
export const StateWithErrorMessage: StoryObj<typeof Select> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', disabled: true },
{ value: 'value2', label: 'Value 2' },
];
const schema = z.object({
selectNames: z.enum(['value0', 'value1']),
});
const {
methods: { trigger },
Form,
} = useConsoleForm({
schema,
});
React.useEffect(() => {
// Use useEffect hook to wait for the form to be rendered before triggering validation
void trigger();
}, []);
return (
<Form onSubmit={action('onSubmit')}>
<Select name="selectNames" label="The select label" options={options} />
</Form>
);
},
...Basic,
name: '🔁 State - With error message',
parameters: {
docs: {
description: {
story: `Incorrect value is set then \`<SimpleForm>\` validation is automatically triggered.`,
},
source: { state: 'open' },
},
},
decorators: [
formDecorator({ triggerValidation: true, withDefaultValue: true }),
],
};
export const TestingScalability: StoryObj<typeof Select> = {
@ -301,12 +161,5 @@ export const TestingScalability: StoryObj<typeof Select> = {
</SimpleForm>
);
},
name: '🧪 Testing - Scalability',
parameters: {
docs: {
source: { state: 'open' },
},
},
};

View File

@ -0,0 +1,432 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { z } from 'zod';
import { SimpleForm, SelectField, useConsoleForm } from '.';
import { FaTable } from 'react-icons/fa';
const formDecorator =
({ triggerValidation = false, withDefaultValue = false } = {}) =>
(Story: React.FC) => {
const schema = z.object({
selectNames: z
.enum(['value0', 'value1', 'value2'])
.refine(val => val !== 'value2', {
message: 'Value2 not suitable for this case',
}),
});
const defaultValues: z.infer<typeof schema> = { selectNames: 'value2' };
const {
methods: { trigger },
Form,
} = useConsoleForm({
schema,
...(withDefaultValue ? { options: { defaultValues } } : {}),
});
React.useEffect(() => {
if (triggerValidation) {
void trigger();
}
}, []);
return (
<Form onSubmit={action('onSubmit')}>
<Story />
</Form>
);
};
const formDecoratorMulti =
({ triggerValidation = false, withDefaultValue = false } = {}) =>
(Story: React.FC) => {
const schema = z.object({
selectNames: z
.enum(['value0', 'value1', 'value2'])
.array()
.nonempty({
message: 'Choose at least one option',
})
.refine(val => !val.includes('value2'), {
message: 'Value2 not suitable for this case',
}),
});
const defaultValues: z.infer<typeof schema> = {
selectNames: ['value0', 'value1'],
};
const {
methods: { trigger },
Form,
} = useConsoleForm({
schema,
...(withDefaultValue ? { options: { defaultValues } } : {}),
});
React.useEffect(() => {
if (triggerValidation) {
void trigger();
}
}, []);
return (
<Form onSubmit={action('onSubmit')}>
<Story />
</Form>
);
};
export default {
title: 'components/Forms 📁/SelectField 🧬',
component: SelectField,
parameters: {
docs: {
description: {
component: `A component wrapping *react-select* component, see [react-select documentation](https://react-select.com/home) for more details, its label,
its description, hint and error message.<br>
Default CSS display is \`block\`, provided without padding and margin (displayed here with the \`<SimpleForm>\` padding).`,
},
source: { type: 'code' },
},
},
} satisfies Meta<typeof SelectField>;
export const ApiPlayground: StoryObj<typeof SelectField> = {
name: '⚙️ API',
args: {
name: 'selectNames',
label: 'Play with me!',
options: [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', isDisabled: true },
{ value: 'value2', label: 'Value 2' },
],
},
decorators: [formDecorator()],
};
export const Basic: StoryObj<typeof SelectField> = {
name: '🧰 Basic',
args: {
name: 'selectNames',
label: 'Play with me!',
options: [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', isDisabled: true },
{ value: 'value2', label: 'Value 2' },
],
},
decorators: [formDecorator()],
};
export const VariantWithDescription: StoryObj<typeof SelectField> = {
...Basic,
name: '🎭 Variant - With description',
args: {
...Basic.args,
description: 'SelectField description',
},
};
export const VariantWithTooltip: StoryObj<typeof SelectField> = {
...Basic,
name: '🎭 Variant - With tooltip',
args: {
...Basic.args,
tooltip: 'SelectField tooltip',
},
};
export const VariantWithIcon: StoryObj<typeof SelectField> = {
...Basic,
name: '🎭 Variant - With Icon',
args: {
...Basic.args,
options: [
{
value: { name: 'Album', schema: 'public' },
label: 'public / Album',
icon: () => <FaTable />,
},
{
value: { name: 'Artist', schema: 'public' },
label: 'public / Artist',
icon: () => <FaTable />,
},
{
value: { name: 'Track', schema: 'public' },
label: 'public / Track',
icon: () => <FaTable />,
},
{
value: { name: 'Genre', schema: 'public' },
label: 'public / Genre',
icon: () => <FaTable />,
},
],
},
};
export const StateWithDefaultValue: StoryObj<typeof SelectField> = {
...Basic,
name: '🔁 State - With default value',
args: {
...Basic.args,
},
decorators: [formDecorator({ withDefaultValue: true })],
};
export const StateGroupedWithDefaultValue: StoryObj<typeof SelectField> = {
...Basic,
name: '🔁 State - Grouped with default value',
args: {
...Basic.args,
options: [
{
label: 'Group 1',
options: [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1' },
{ value: 'value2', label: 'Value 2' },
],
},
{
label: 'Group 2',
options: [
{ value: 'value3', label: 'Value 3' },
{ value: 'value4', label: 'Value 4', isDisabled: true },
{ value: 'value5', label: 'Value 5' },
],
},
],
},
decorators: [formDecorator({ withDefaultValue: true })],
};
export const StateLoading: StoryObj<typeof SelectField> = {
...Basic,
name: '🔁 State - Loading',
args: {
...Basic.args,
loading: true,
},
};
export const StateDisabled: StoryObj<typeof SelectField> = {
...Basic,
name: '🔁 State - Disabled',
args: {
...Basic.args,
disabled: true,
},
};
export const StateWithErrorMessage: StoryObj<typeof SelectField> = {
...Basic,
name: '🔁 State - With error message',
decorators: [
formDecorator({ triggerValidation: true, withDefaultValue: true }),
],
};
export const TestingScalability: StoryObj<typeof SelectField> = {
render: () => {
const options = [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', disabled: true },
{ value: 'value2', label: 'Value 2' },
{
value: 'value3',
label:
'Value 4 - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
},
];
const validationSchema = z.object({});
return (
<SimpleForm schema={validationSchema} onSubmit={action('onSubmit')}>
<SelectField
name="selectNames"
label="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
tooltip="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
options={options}
/>
</SimpleForm>
);
},
name: '🧪 Testing - Scalability',
};
export const BasicMulti: StoryObj<typeof SelectField> = {
name: '🧰 Basic multi',
args: {
name: 'selectNames',
label: 'Play with me!',
multi: true,
options: [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1', isDisabled: true },
{ value: 'value2', label: 'Value 2' },
],
},
decorators: [formDecoratorMulti()],
};
export const StateWithDefaultValueMulti: StoryObj<typeof SelectField> = {
...BasicMulti,
name: '🔁 State - Multi with default value',
args: {
...BasicMulti.args,
multi: true,
},
decorators: [formDecoratorMulti({ withDefaultValue: true })],
};
export const StateGroupedWithDefaultValueMulti: StoryObj<typeof SelectField> = {
...BasicMulti,
name: '🔁 State - Multi grouped with default value',
args: {
...BasicMulti.args,
options: [
{
label: 'Group 1',
options: [
{ value: 'value0', label: 'Value 0' },
{ value: 'value1', label: 'Value 1' },
{ value: 'value2', label: 'Value 2' },
],
},
{
label: 'Group 2',
options: [
{ value: 'value3', label: 'Value 3' },
{ value: 'value4', label: 'Value 4', isDisabled: true },
{ value: 'value5', label: 'Value 5' },
],
},
],
},
decorators: [formDecoratorMulti({ withDefaultValue: true })],
};
const formDecoratorObject =
({ triggerValidation = false, withDefaultValue = false } = {}) =>
(Story: React.FC) => {
const schema = z.object({
selectNames: z.object({ key: z.enum(['value0', 'value1', 'value2']) }),
});
const defaultValues: z.infer<typeof schema> = {
selectNames: { key: 'value1' },
};
const {
methods: { trigger },
Form,
} = useConsoleForm({
schema,
...(withDefaultValue ? { options: { defaultValues } } : {}),
});
React.useEffect(() => {
if (triggerValidation) {
void trigger();
}
}, []);
return (
<Form onSubmit={action('onSubmit')}>
<Story />
</Form>
);
};
export const BasicWithObjectAsValue: StoryObj<typeof SelectField> = {
name: '🧰 Basic with object as default value',
args: {
name: 'selectNames',
label: 'Play with me!',
options: [
{ value: { key: 'value0' }, label: 'Value 0' },
{ value: { key: 'value1' }, label: 'Value 1' },
{ value: { key: 'value2' }, label: 'Value 2' },
],
},
decorators: [
formDecoratorObject({ triggerValidation: true, withDefaultValue: true }),
],
};
const formDecoratorObjectMulti =
({ triggerValidation = false, withDefaultValue = false } = {}) =>
(Story: React.FC) => {
const schema = z.object({
selectNames: z
.array(z.object({ key: z.enum(['value0', 'value1', 'value2']) }))
.nonempty({
message: 'Choose at least one option',
})
.refine(val => !val.includes({ key: 'value2' }), {
message: 'Value2 not suitable for this case',
}),
});
const defaultValues: z.infer<typeof schema> = {
selectNames: [{ key: 'value0' }, { key: 'value1' }],
};
const {
methods: { trigger },
Form,
} = useConsoleForm({
schema,
...(withDefaultValue ? { options: { defaultValues } } : {}),
});
React.useEffect(() => {
if (triggerValidation) {
void trigger();
}
}, []);
return (
<Form onSubmit={action('onSubmit')}>
<Story />
</Form>
);
};
export const BasicMultiWithObjectAsValue: StoryObj<typeof SelectField> = {
name: '🧰 Basic multi with object as default value',
args: {
name: 'selectNames',
label: 'Play with me!',
options: [
{
label: 'Group 1',
options: [
{ value: { key: 'value0' }, label: 'Value 0' },
{ value: { key: 'value1' }, label: 'Value 1' },
{ value: { key: 'value2' }, label: 'Value 2' },
],
},
{
label: 'Group 2',
options: [
{ value: { key: 'value3' }, label: 'Value 3' },
{ value: { key: 'value4' }, label: 'Value 4', isDisabled: true },
{ value: { key: 'value5' }, label: 'Value 5' },
],
},
],
multi: true,
},
decorators: [
formDecoratorObjectMulti({
triggerValidation: true,
withDefaultValue: true,
}),
],
};

View File

@ -0,0 +1,149 @@
import React from 'react';
import get from 'lodash/get';
import { Controller, FieldError, useFormContext } from 'react-hook-form';
import { FieldWrapper, FieldWrapperPassThroughProps } from './FieldWrapper';
import {
ReactSelect,
ReactSelectProps,
ReactSelectOptionType,
} from './ReactSelect';
import { Option } from 'react-select/src/filters';
import { GroupBase, PropsValue } from 'react-select';
import isEqual from 'lodash/isEqual';
const includesObject = (arr: any[], obj: any) => {
return arr?.some && arr.some(item => isEqual(item, obj));
};
export type SelectFieldProps = FieldWrapperPassThroughProps & {
/**
* The field name
*/
name: string;
/**
* The options to display in the select
*/
options: ReactSelectProps<ReactSelectOptionType>['options'];
/**
* The placeholder text to display when the field is not valued
*/
placeholder?: string;
/**
* Flag to indicate if the field is disabled
*/
disabled?: boolean;
/**
* Flag to indicate if the field is multi-select
*/
multi?: boolean;
/**
* The value of the field
*/
value?: PropsValue<ReactSelectOptionType['value']> | undefined;
/**
* The default value of the field
*/
defaultValue?: PropsValue<ReactSelectOptionType['value']> | undefined;
/**
* The wrapped react-select component props
*/
selectProps?: Omit<
ReactSelectProps<ReactSelectOptionType>,
'options' | 'value' | 'name' | 'isDisabled' | 'isMulti' | 'defaultValue'
>;
};
export const SelectField: React.VFC<SelectFieldProps> = ({
name,
options,
placeholder,
dataTest,
disabled = false,
multi = false,
selectProps,
value: propValue,
...props
}) => {
const {
control,
formState: { errors },
} = useFormContext();
const maybeError = get(errors, name) as FieldError | undefined;
return (
<FieldWrapper id={name} {...props} error={maybeError}>
<Controller
name={name}
control={control}
render={({ field: { value, name: controllerName, onChange } }) => {
const handleChange = (val: any) => {
if (multi) {
onChange(val.map((v: any) => v?.value));
} else {
onChange(val?.value);
}
};
const extractValue = (
rawValue: PropsValue<ReactSelectOptionType['value']> | undefined,
optionsReference: typeof options
): PropsValue<ReactSelectOptionType['value']> | undefined => {
return optionsReference
?.map(option => {
if ((option as GroupBase<Option>)?.options) {
return (
(option as GroupBase<Option>).options as Option[]
).filter(
innerOption =>
rawValue === innerOption?.value ||
isEqual(rawValue, innerOption?.value) ||
(rawValue?.includes &&
rawValue?.includes(innerOption.value)) ||
includesObject(rawValue, innerOption.value)
);
} else {
return option;
}
})
.flat()
.filter(
(
option:
| ReactSelectOptionType
| GroupBase<ReactSelectOptionType>
) => {
if ((option as GroupBase<ReactSelectOptionType>).options) {
return true;
} else {
const simpleOption = option as ReactSelectOptionType;
return (
rawValue === simpleOption.value ||
isEqual(rawValue, simpleOption.value) ||
(rawValue?.includes &&
rawValue?.includes(simpleOption.value)) ||
includesObject(rawValue, simpleOption.value)
);
}
}
);
};
return (
<ReactSelect
name={controllerName}
placeholder={placeholder}
isDisabled={disabled}
isInvalid={!!maybeError}
isMulti={multi}
options={options}
value={extractValue(value, options)}
data-test={dataTest}
data-testid={name}
onChange={handleChange}
/>
);
}}
/>
</FieldWrapper>
);
};

View File

@ -9,6 +9,7 @@ import {
CodeEditorField,
InputField,
Radio,
SelectField,
Select,
Textarea,
useConsoleForm,
@ -211,7 +212,14 @@ export const AllInputs: StoryObj<any> = {
const schema = z.object({
inputFieldName: z.string().min(1, { message: 'Mandatory field' }),
textareaName: z.string().min(1, { message: 'Mandatory field' }),
simpleSelectName: z.string().min(1, { message: 'Mandatory field' }),
selectName: z.string().min(1, { message: 'Mandatory field' }),
multiSelectName: z
.enum(['multiSelectValue0', 'multiSelectValue1', 'multiSelectValue2'])
.array()
.nonempty({
message: 'Choose at least one option',
}),
checkboxesFieldNames: z
.enum(['checkboxValue0', 'checkboxValue1', 'checkboxValue2'])
.array()
@ -267,6 +275,22 @@ export const AllInputs: StoryObj<any> = {
placeholder="Textarea field placeholder"
/>
<Select
name="simpleSelectName"
options={[
{ value: 'simpleSelectValue0', label: 'Simple select value 0' },
{
value: 'simpleSelectValue1',
label: 'Simple select value 1',
disabled: true,
},
{ value: 'simpleSelectValue2', label: 'Simple select value 2' },
]}
label="The simple select label *"
description="The simple select description"
tooltip="The simple select tooltip"
placeholder="--Simple select placeholder--"
/>
<SelectField
name="selectName"
options={[
{ value: 'selectValue0', label: 'Select value 0' },
@ -282,6 +306,33 @@ export const AllInputs: StoryObj<any> = {
tooltip="The select tooltip"
placeholder="--Select placeholder--"
/>
<SelectField
name="multiSelectName"
options={[
{
label: 'Group 0',
options: [
{ value: 'multiSelectValue0', label: 'Multi select value 0' },
],
},
{
label: 'Group 1',
options: [
{
value: 'multiSelectValue1',
label: 'Multi select value 1',
disabled: true,
},
{ value: 'multiSelectValue2', label: 'Multi select value 2' },
],
},
]}
label="The multi select label *"
description="The multi select description"
tooltip="The multi select tooltip"
placeholder="--Multi select placeholder--"
multi
/>
<CheckboxesField
name="checkboxesFieldNames"
label="The checkbox label *"

View File

@ -14,6 +14,13 @@ import {
UseConsoleFormProps,
} from './form.types';
export type UseConsoleFormReturn = {
methods: ReturnType<typeof useReactHookForm>;
Form: <TFieldValues extends FieldValues>(
props: FormProps<TFieldValues>
) => JSX.Element;
};
// available as a standlone if needed for advanced usage
const ConsoleFormWrapper = <
TFieldValues extends FieldValues,
@ -44,7 +51,7 @@ const ConsoleFormWrapper = <
export const useConsoleForm = <FormSchema extends Schema>(
hookProps: UseConsoleFormProps<zodInfer<FormSchema>, FormSchema>
) => {
): UseConsoleFormReturn => {
const { options = {}, schema } = hookProps;
const methods = useReactHookForm<zodInfer<FormSchema>>({

View File

@ -9,9 +9,9 @@ export type { InputProps } from './Input';
export * from './GraphQLSanitizedInputField';
export * from './Radio';
export * from './Select';
export * from './AdvancedSelect';
export * from './AdvancedSelectField';
export * from './Textarea';
export * from './FieldWrapper';
export * from './hooks/useConsoleForm';
export * from './CopyableInputField';
export * from './ReactSelect';
export * from './SelectField';

View File

@ -0,0 +1,258 @@
const diacritics = [
{
base: 'A',
letters:
'\u0041\u24B6\uFF21\u00C0\u00C1\u00C2\u1EA6\u1EA4\u1EAA\u1EA8\u00C3\u0100\u0102\u1EB0\u1EAE\u1EB4\u1EB2\u0226\u01E0\u00C4\u01DE\u1EA2\u00C5\u01FA\u01CD\u0200\u0202\u1EA0\u1EAC\u1EB6\u1E00\u0104\u023A\u2C6F',
},
{ base: 'AA', letters: '\uA732' },
{ base: 'AE', letters: '\u00C6\u01FC\u01E2' },
{ base: 'AO', letters: '\uA734' },
{ base: 'AU', letters: '\uA736' },
{ base: 'AV', letters: '\uA738\uA73A' },
{ base: 'AY', letters: '\uA73C' },
{
base: 'B',
letters: '\u0042\u24B7\uFF22\u1E02\u1E04\u1E06\u0243\u0182\u0181',
},
{
base: 'C',
letters:
'\u0043\u24B8\uFF23\u0106\u0108\u010A\u010C\u00C7\u1E08\u0187\u023B\uA73E',
},
{
base: 'D',
letters:
'\u0044\u24B9\uFF24\u1E0A\u010E\u1E0C\u1E10\u1E12\u1E0E\u0110\u018B\u018A\u0189\uA779',
},
{ base: 'DZ', letters: '\u01F1\u01C4' },
{ base: 'Dz', letters: '\u01F2\u01C5' },
{
base: 'E',
letters:
'\u0045\u24BA\uFF25\u00C8\u00C9\u00CA\u1EC0\u1EBE\u1EC4\u1EC2\u1EBC\u0112\u1E14\u1E16\u0114\u0116\u00CB\u1EBA\u011A\u0204\u0206\u1EB8\u1EC6\u0228\u1E1C\u0118\u1E18\u1E1A\u0190\u018E',
},
{ base: 'F', letters: '\u0046\u24BB\uFF26\u1E1E\u0191\uA77B' },
{
base: 'G',
letters:
'\u0047\u24BC\uFF27\u01F4\u011C\u1E20\u011E\u0120\u01E6\u0122\u01E4\u0193\uA7A0\uA77D\uA77E',
},
{
base: 'H',
letters:
'\u0048\u24BD\uFF28\u0124\u1E22\u1E26\u021E\u1E24\u1E28\u1E2A\u0126\u2C67\u2C75\uA78D',
},
{
base: 'I',
letters:
'\u0049\u24BE\uFF29\u00CC\u00CD\u00CE\u0128\u012A\u012C\u0130\u00CF\u1E2E\u1EC8\u01CF\u0208\u020A\u1ECA\u012E\u1E2C\u0197',
},
{ base: 'J', letters: '\u004A\u24BF\uFF2A\u0134\u0248' },
{
base: 'K',
letters:
'\u004B\u24C0\uFF2B\u1E30\u01E8\u1E32\u0136\u1E34\u0198\u2C69\uA740\uA742\uA744\uA7A2',
},
{
base: 'L',
letters:
'\u004C\u24C1\uFF2C\u013F\u0139\u013D\u1E36\u1E38\u013B\u1E3C\u1E3A\u0141\u023D\u2C62\u2C60\uA748\uA746\uA780',
},
{ base: 'LJ', letters: '\u01C7' },
{ base: 'Lj', letters: '\u01C8' },
{ base: 'M', letters: '\u004D\u24C2\uFF2D\u1E3E\u1E40\u1E42\u2C6E\u019C' },
{
base: 'N',
letters:
'\u004E\u24C3\uFF2E\u01F8\u0143\u00D1\u1E44\u0147\u1E46\u0145\u1E4A\u1E48\u0220\u019D\uA790\uA7A4',
},
{ base: 'NJ', letters: '\u01CA' },
{ base: 'Nj', letters: '\u01CB' },
{
base: 'O',
letters:
'\u004F\u24C4\uFF2F\u00D2\u00D3\u00D4\u1ED2\u1ED0\u1ED6\u1ED4\u00D5\u1E4C\u022C\u1E4E\u014C\u1E50\u1E52\u014E\u022E\u0230\u00D6\u022A\u1ECE\u0150\u01D1\u020C\u020E\u01A0\u1EDC\u1EDA\u1EE0\u1EDE\u1EE2\u1ECC\u1ED8\u01EA\u01EC\u00D8\u01FE\u0186\u019F\uA74A\uA74C',
},
{ base: 'OI', letters: '\u01A2' },
{ base: 'OO', letters: '\uA74E' },
{ base: 'OU', letters: '\u0222' },
{
base: 'P',
letters: '\u0050\u24C5\uFF30\u1E54\u1E56\u01A4\u2C63\uA750\uA752\uA754',
},
{ base: 'Q', letters: '\u0051\u24C6\uFF31\uA756\uA758\u024A' },
{
base: 'R',
letters:
'\u0052\u24C7\uFF32\u0154\u1E58\u0158\u0210\u0212\u1E5A\u1E5C\u0156\u1E5E\u024C\u2C64\uA75A\uA7A6\uA782',
},
{
base: 'S',
letters:
'\u0053\u24C8\uFF33\u1E9E\u015A\u1E64\u015C\u1E60\u0160\u1E66\u1E62\u1E68\u0218\u015E\u2C7E\uA7A8\uA784',
},
{
base: 'T',
letters:
'\u0054\u24C9\uFF34\u1E6A\u0164\u1E6C\u021A\u0162\u1E70\u1E6E\u0166\u01AC\u01AE\u023E\uA786',
},
{ base: 'TZ', letters: '\uA728' },
{
base: 'U',
letters:
'\u0055\u24CA\uFF35\u00D9\u00DA\u00DB\u0168\u1E78\u016A\u1E7A\u016C\u00DC\u01DB\u01D7\u01D5\u01D9\u1EE6\u016E\u0170\u01D3\u0214\u0216\u01AF\u1EEA\u1EE8\u1EEE\u1EEC\u1EF0\u1EE4\u1E72\u0172\u1E76\u1E74\u0244',
},
{ base: 'V', letters: '\u0056\u24CB\uFF36\u1E7C\u1E7E\u01B2\uA75E\u0245' },
{ base: 'VY', letters: '\uA760' },
{
base: 'W',
letters: '\u0057\u24CC\uFF37\u1E80\u1E82\u0174\u1E86\u1E84\u1E88\u2C72',
},
{ base: 'X', letters: '\u0058\u24CD\uFF38\u1E8A\u1E8C' },
{
base: 'Y',
letters:
'\u0059\u24CE\uFF39\u1EF2\u00DD\u0176\u1EF8\u0232\u1E8E\u0178\u1EF6\u1EF4\u01B3\u024E\u1EFE',
},
{
base: 'Z',
letters:
'\u005A\u24CF\uFF3A\u0179\u1E90\u017B\u017D\u1E92\u1E94\u01B5\u0224\u2C7F\u2C6B\uA762',
},
{
base: 'a',
letters:
'\u0061\u24D0\uFF41\u1E9A\u00E0\u00E1\u00E2\u1EA7\u1EA5\u1EAB\u1EA9\u00E3\u0101\u0103\u1EB1\u1EAF\u1EB5\u1EB3\u0227\u01E1\u00E4\u01DF\u1EA3\u00E5\u01FB\u01CE\u0201\u0203\u1EA1\u1EAD\u1EB7\u1E01\u0105\u2C65\u0250',
},
{ base: 'aa', letters: '\uA733' },
{ base: 'ae', letters: '\u00E6\u01FD\u01E3' },
{ base: 'ao', letters: '\uA735' },
{ base: 'au', letters: '\uA737' },
{ base: 'av', letters: '\uA739\uA73B' },
{ base: 'ay', letters: '\uA73D' },
{
base: 'b',
letters: '\u0062\u24D1\uFF42\u1E03\u1E05\u1E07\u0180\u0183\u0253',
},
{
base: 'c',
letters:
'\u0063\u24D2\uFF43\u0107\u0109\u010B\u010D\u00E7\u1E09\u0188\u023C\uA73F\u2184',
},
{
base: 'd',
letters:
'\u0064\u24D3\uFF44\u1E0B\u010F\u1E0D\u1E11\u1E13\u1E0F\u0111\u018C\u0256\u0257\uA77A',
},
{ base: 'dz', letters: '\u01F3\u01C6' },
{
base: 'e',
letters:
'\u0065\u24D4\uFF45\u00E8\u00E9\u00EA\u1EC1\u1EBF\u1EC5\u1EC3\u1EBD\u0113\u1E15\u1E17\u0115\u0117\u00EB\u1EBB\u011B\u0205\u0207\u1EB9\u1EC7\u0229\u1E1D\u0119\u1E19\u1E1B\u0247\u025B\u01DD',
},
{ base: 'f', letters: '\u0066\u24D5\uFF46\u1E1F\u0192\uA77C' },
{
base: 'g',
letters:
'\u0067\u24D6\uFF47\u01F5\u011D\u1E21\u011F\u0121\u01E7\u0123\u01E5\u0260\uA7A1\u1D79\uA77F',
},
{
base: 'h',
letters:
'\u0068\u24D7\uFF48\u0125\u1E23\u1E27\u021F\u1E25\u1E29\u1E2B\u1E96\u0127\u2C68\u2C76\u0265',
},
{ base: 'hv', letters: '\u0195' },
{
base: 'i',
letters:
'\u0069\u24D8\uFF49\u00EC\u00ED\u00EE\u0129\u012B\u012D\u00EF\u1E2F\u1EC9\u01D0\u0209\u020B\u1ECB\u012F\u1E2D\u0268\u0131',
},
{ base: 'j', letters: '\u006A\u24D9\uFF4A\u0135\u01F0\u0249' },
{
base: 'k',
letters:
'\u006B\u24DA\uFF4B\u1E31\u01E9\u1E33\u0137\u1E35\u0199\u2C6A\uA741\uA743\uA745\uA7A3',
},
{
base: 'l',
letters:
'\u006C\u24DB\uFF4C\u0140\u013A\u013E\u1E37\u1E39\u013C\u1E3D\u1E3B\u017F\u0142\u019A\u026B\u2C61\uA749\uA781\uA747',
},
{ base: 'lj', letters: '\u01C9' },
{ base: 'm', letters: '\u006D\u24DC\uFF4D\u1E3F\u1E41\u1E43\u0271\u026F' },
{
base: 'n',
letters:
'\u006E\u24DD\uFF4E\u01F9\u0144\u00F1\u1E45\u0148\u1E47\u0146\u1E4B\u1E49\u019E\u0272\u0149\uA791\uA7A5',
},
{ base: 'nj', letters: '\u01CC' },
{
base: 'o',
letters:
'\u006F\u24DE\uFF4F\u00F2\u00F3\u00F4\u1ED3\u1ED1\u1ED7\u1ED5\u00F5\u1E4D\u022D\u1E4F\u014D\u1E51\u1E53\u014F\u022F\u0231\u00F6\u022B\u1ECF\u0151\u01D2\u020D\u020F\u01A1\u1EDD\u1EDB\u1EE1\u1EDF\u1EE3\u1ECD\u1ED9\u01EB\u01ED\u00F8\u01FF\u0254\uA74B\uA74D\u0275',
},
{ base: 'oi', letters: '\u01A3' },
{ base: 'ou', letters: '\u0223' },
{ base: 'oo', letters: '\uA74F' },
{
base: 'p',
letters: '\u0070\u24DF\uFF50\u1E55\u1E57\u01A5\u1D7D\uA751\uA753\uA755',
},
{ base: 'q', letters: '\u0071\u24E0\uFF51\u024B\uA757\uA759' },
{
base: 'r',
letters:
'\u0072\u24E1\uFF52\u0155\u1E59\u0159\u0211\u0213\u1E5B\u1E5D\u0157\u1E5F\u024D\u027D\uA75B\uA7A7\uA783',
},
{
base: 's',
letters:
'\u0073\u24E2\uFF53\u00DF\u015B\u1E65\u015D\u1E61\u0161\u1E67\u1E63\u1E69\u0219\u015F\u023F\uA7A9\uA785\u1E9B',
},
{
base: 't',
letters:
'\u0074\u24E3\uFF54\u1E6B\u1E97\u0165\u1E6D\u021B\u0163\u1E71\u1E6F\u0167\u01AD\u0288\u2C66\uA787',
},
{ base: 'tz', letters: '\uA729' },
{
base: 'u',
letters:
'\u0075\u24E4\uFF55\u00F9\u00FA\u00FB\u0169\u1E79\u016B\u1E7B\u016D\u00FC\u01DC\u01D8\u01D6\u01DA\u1EE7\u016F\u0171\u01D4\u0215\u0217\u01B0\u1EEB\u1EE9\u1EEF\u1EED\u1EF1\u1EE5\u1E73\u0173\u1E77\u1E75\u0289',
},
{ base: 'v', letters: '\u0076\u24E5\uFF56\u1E7D\u1E7F\u028B\uA75F\u028C' },
{ base: 'vy', letters: '\uA761' },
{
base: 'w',
letters:
'\u0077\u24E6\uFF57\u1E81\u1E83\u0175\u1E87\u1E85\u1E98\u1E89\u2C73',
},
{ base: 'x', letters: '\u0078\u24E7\uFF58\u1E8B\u1E8D' },
{
base: 'y',
letters:
'\u0079\u24E8\uFF59\u1EF3\u00FD\u0177\u1EF9\u0233\u1E8F\u00FF\u1EF7\u1E99\u1EF5\u01B4\u024F\u1EFF',
},
{
base: 'z',
letters:
'\u007A\u24E9\uFF5A\u017A\u1E91\u017C\u017E\u1E93\u1E95\u01B6\u0225\u0240\u2C6C\uA763',
},
];
const anyDiacritic = new RegExp(
'[' + diacritics.map(d => d.letters).join('') + ']',
'g'
);
const diacriticToBase: { [letters: string]: string } = {};
for (let i = 0; i < diacritics.length; i++) {
const diacritic = diacritics[i];
for (let j = 0; j < diacritic.letters.length; j++) {
diacriticToBase[diacritic.letters[j]] = diacritic.base;
}
}
export const stripDiacritics = (str: string) => {
return str.replace(anyDiacritic, match => diacriticToBase[match]);
};

View File

@ -114,6 +114,7 @@
"moment-timezone": "^0.5.27",
"oas-validator": "^5.0.8",
"piping": "0.3.2",
"pluralize": "^8.0.0",
"posthog-js": "^1.4.3",
"pretty-error": "1.2.0",
"prop-types": "15.7.2",
@ -147,10 +148,11 @@
"react-redux": "7.2.4",
"react-router": "3.2.6",
"react-router-redux": "4.0.8",
"react-select": "2.4.4",
"react-select": "5.7.3",
"react-table": "6.11.5",
"react-tabs": "3.1.0",
"react-toggle": "4.1.1",
"react-window": "^1.8.9",
"react-youtube": "^7.13.0",
"redux": "4.1.0",
"redux-thunk": "2.3.0",
@ -268,6 +270,7 @@
"@types/react-router-redux": "4.0.44",
"@types/react-select": "3.0.12",
"@types/react-toggle": "4.0.2",
"@types/react-window": "^1.8.5",
"@types/react-youtube": "^7.6.2",
"@types/redux-devtools": "3.0.47",
"@types/redux-devtools-dock-monitor": "1.1.33",

File diff suppressed because it is too large Load Diff