Refactor default value for select (#5343)

In this PR, we are refactoring two things:
- leverage field.defaultValue for Select and MultiSelect settings form
(instead of option.isDefault)
- use quoted string (ex: "'USD'") for string default values to embrace
backend format

---------

Co-authored-by: Thaïs Guigon <guigon.thais@gmail.com>
This commit is contained in:
Charles Bochet
2024-05-10 10:26:46 +02:00
committed by GitHub
parent 7728c09dba
commit 8590bd7227
40 changed files with 843 additions and 559 deletions

View File

@ -1,66 +0,0 @@
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes';
import { Select } from '@/ui/input/components/Select';
import { CardContent } from '@/ui/layout/card/components/CardContent';
// TODO: rename to SettingsDataModelFieldCurrencyForm and move to settings/data-model/fields/forms/components
export const settingsDataModelFieldCurrencyFormSchema = z.object({
defaultValue: z.object({
currencyCode: z.nativeEnum(CurrencyCode),
}),
});
type SettingsDataModelFieldCurrencyFormValues = z.infer<
typeof settingsDataModelFieldCurrencyFormSchema
>;
type SettingsDataModelFieldCurrencyFormProps = {
disabled?: boolean;
fieldMetadataItem?: Pick<FieldMetadataItem, 'defaultValue'>;
};
const OPTIONS = Object.entries(SETTINGS_FIELD_CURRENCY_CODES).map(
([value, { label, Icon }]) => ({
label,
value: value as CurrencyCode,
Icon,
}),
);
export const SettingsDataModelFieldCurrencyForm = ({
disabled,
fieldMetadataItem,
}: SettingsDataModelFieldCurrencyFormProps) => {
const { control } =
useFormContext<SettingsDataModelFieldCurrencyFormValues>();
const initialValue =
(fieldMetadataItem?.defaultValue?.currencyCode as CurrencyCode) ??
CurrencyCode.USD;
return (
<CardContent>
<Controller
name="defaultValue.currencyCode"
control={control}
defaultValue={initialValue}
render={({ field: { onChange, value } }) => (
<Select
fullWidth
disabled={disabled}
label="Default Unit"
dropdownId="currency-unit-select"
value={value}
options={OPTIONS}
onChange={onChange}
/>
)}
/>
</CardContent>
);
};

View File

@ -2,47 +2,54 @@ import { Controller, useFormContext } from 'react-hook-form';
import styled from '@emotion/styled';
import { DropResult } from '@hello-pangea/dnd';
import { IconPlus } from 'twenty-ui';
import { v4 } from 'uuid';
import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsObjectFieldSelectFormOption } from '@/settings/data-model/types/SettingsObjectFieldSelectFormOption';
import {
FieldMetadataItem,
FieldMetadataItemOption,
} from '@/object-metadata/types/FieldMetadataItem';
import { selectOptionsSchema } from '@/object-metadata/validation-schemas/selectOptionsSchema';
import { useSelectSettingsFormInitialValues } from '@/settings/data-model/fields/forms/hooks/useSelectSettingsFormInitialValues';
import { generateNewSelectOption } from '@/settings/data-model/fields/forms/utils/generateNewSelectOption';
import { isSelectOptionDefaultValue } from '@/settings/data-model/utils/isSelectOptionDefaultValue';
import { LightButton } from '@/ui/input/button/components/LightButton';
import { CardContent } from '@/ui/layout/card/components/CardContent';
import { CardFooter } from '@/ui/layout/card/components/CardFooter';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import {
MAIN_COLOR_NAMES,
ThemeColor,
} from '@/ui/theme/constants/MainColorNames';
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { moveArrayItem } from '~/utils/array/moveArrayItem';
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
import { simpleQuotesStringSchema } from '~/utils/validation-schemas/simpleQuotesStringSchema';
import { SettingsObjectFieldSelectFormOptionRow } from './SettingsObjectFieldSelectFormOptionRow';
// TODO: rename to SettingsDataModelFieldSelectForm and move to settings/data-model/fields/forms/components
export const settingsDataModelFieldSelectFormSchema = z.object({
options: z
.array(
z.object({
color: themeColorSchema,
value: z.string(),
isDefault: z.boolean().optional(),
label: z.string().min(1),
}),
)
.min(1),
defaultValue: simpleQuotesStringSchema.nullable(),
options: selectOptionsSchema,
});
export const settingsDataModelFieldMultiSelectFormSchema = z.object({
defaultValue: z.array(simpleQuotesStringSchema).nullable(),
options: selectOptionsSchema,
});
const selectOrMultiSelectFormSchema = z.union([
settingsDataModelFieldSelectFormSchema,
settingsDataModelFieldMultiSelectFormSchema,
]);
export type SettingsDataModelFieldSelectFormValues = z.infer<
typeof settingsDataModelFieldSelectFormSchema
typeof selectOrMultiSelectFormSchema
>;
type SettingsDataModelFieldSelectFormProps = {
fieldMetadataItem?: Pick<FieldMetadataItem, 'defaultValue' | 'options'>;
isMultiSelect?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'defaultValue' | 'options' | 'type'
>;
};
const StyledContainer = styled(CardContent)`
@ -68,155 +75,178 @@ const StyledButton = styled(LightButton)`
width: 100%;
`;
const getNextColor = (currentColor: ThemeColor) => {
const currentColorIndex = MAIN_COLOR_NAMES.findIndex(
(color) => color === currentColor,
);
const nextColorIndex = (currentColorIndex + 1) % MAIN_COLOR_NAMES.length;
return MAIN_COLOR_NAMES[nextColorIndex];
};
const getDefaultValueOptionIndexes = (
fieldMetadataItem?: Pick<FieldMetadataItem, 'defaultValue' | 'options'>,
) =>
fieldMetadataItem?.options?.reduce<number[]>((result, option, index) => {
if (
Array.isArray(fieldMetadataItem?.defaultValue) &&
fieldMetadataItem?.defaultValue.includes(`'${option.value}'`)
) {
return [...result, index];
}
// Ensure default value is unique for simple Select field
if (
!result.length &&
fieldMetadataItem?.defaultValue === `'${option.value}'`
) {
return [index];
}
return result;
}, []);
const DEFAULT_OPTION: SettingsObjectFieldSelectFormOption = {
color: 'green',
label: 'Option 1',
value: v4(),
};
export const SettingsDataModelFieldSelectForm = ({
fieldMetadataItem,
isMultiSelect = false,
}: SettingsDataModelFieldSelectFormProps) => {
const { control } = useFormContext<SettingsDataModelFieldSelectFormValues>();
const { initialDefaultValue, initialOptions } =
useSelectSettingsFormInitialValues({ fieldMetadataItem });
const initialDefaultValueOptionIndexes =
getDefaultValueOptionIndexes(fieldMetadataItem);
const initialValue = fieldMetadataItem?.options
?.map((option, index) => ({
...option,
isDefault: initialDefaultValueOptionIndexes?.includes(index),
}))
.sort((optionA, optionB) => optionA.position - optionB.position);
const {
control,
setValue: setFormValue,
watch: watchFormValue,
getValues,
} = useFormContext<SettingsDataModelFieldSelectFormValues>();
const handleDragEnd = (
values: SettingsObjectFieldSelectFormOption[],
values: FieldMetadataItemOption[],
result: DropResult,
onChange: (options: SettingsObjectFieldSelectFormOption[]) => void,
onChange: (options: FieldMetadataItemOption[]) => void,
) => {
if (!result.destination) return;
const nextOptions = moveArrayItem(values, {
fromIndex: result.source.index,
toIndex: result.destination.index,
});
}).map((option, index) => ({ ...option, position: index }));
onChange(nextOptions);
};
const findNewLabel = (values: SettingsObjectFieldSelectFormOption[]) => {
let optionIndex = values.length + 1;
while (optionIndex < 100) {
const newLabel = `Option ${optionIndex}`;
if (!values.map((value) => value.label).includes(newLabel)) {
return newLabel;
}
optionIndex += 1;
const isOptionDefaultValue = (
optionValue: FieldMetadataItemOption['value'],
) =>
isSelectOptionDefaultValue(optionValue, {
type: fieldMetadataItem.type,
defaultValue: watchFormValue('defaultValue'),
});
const handleSetOptionAsDefault = (
optionValue: FieldMetadataItemOption['value'],
) => {
if (isOptionDefaultValue(optionValue)) return;
if (fieldMetadataItem.type === FieldMetadataType.Select) {
setFormValue('defaultValue', applySimpleQuotesToString(optionValue), {
shouldDirty: true,
});
return;
}
const previousDefaultValue = getValues('defaultValue');
if (
fieldMetadataItem.type === FieldMetadataType.MultiSelect &&
(Array.isArray(previousDefaultValue) || previousDefaultValue === null)
) {
setFormValue(
'defaultValue',
[
...(previousDefaultValue ?? []),
applySimpleQuotesToString(optionValue),
],
{ shouldDirty: true },
);
}
};
const handleRemoveOptionAsDefault = (
optionValue: FieldMetadataItemOption['value'],
) => {
if (!isOptionDefaultValue(optionValue)) return;
if (fieldMetadataItem.type === FieldMetadataType.Select) {
setFormValue('defaultValue', null, { shouldDirty: true });
return;
}
const previousDefaultValue = getValues('defaultValue');
if (
fieldMetadataItem.type === FieldMetadataType.MultiSelect &&
(Array.isArray(previousDefaultValue) || previousDefaultValue === null)
) {
const nextDefaultValue = previousDefaultValue?.filter(
(value) => value !== applySimpleQuotesToString(optionValue),
);
setFormValue(
'defaultValue',
nextDefaultValue?.length ? nextDefaultValue : null,
{ shouldDirty: true },
);
}
return `Option 100`;
};
return (
<Controller
name="options"
control={control}
defaultValue={initialValue?.length ? initialValue : [DEFAULT_OPTION]}
render={({ field: { onChange, value: options } }) => (
<>
<StyledContainer>
<StyledLabel>Options</StyledLabel>
<DraggableList
onDragEnd={(result) => handleDragEnd(options, result, onChange)}
draggableItems={
<>
{options.map((option, index) => (
<DraggableItem
key={option.value}
draggableId={option.value}
index={index}
isDragDisabled={options.length === 1}
itemComponent={
<SettingsObjectFieldSelectFormOptionRow
key={option.value}
isDefault={option.isDefault}
onChange={(nextOption) => {
const nextOptions =
isMultiSelect || !nextOption.isDefault
? [...options]
: // Reset simple Select default option before setting the new one
options.map<SettingsObjectFieldSelectFormOption>(
(value) => ({ ...value, isDefault: false }),
);
nextOptions.splice(index, 1, nextOption);
onChange(nextOptions);
}}
onRemove={
options.length > 1
? () => {
const nextOptions = [...options];
nextOptions.splice(index, 1);
onChange(nextOptions);
}
: undefined
}
option={option}
/>
}
/>
))}
</>
}
/>
</StyledContainer>
<StyledFooter>
<StyledButton
title="Add option"
Icon={IconPlus}
onClick={() =>
onChange([
...options,
{
color: getNextColor(options[options.length - 1].color),
label: findNewLabel(options),
value: v4(),
},
])
}
/>
</StyledFooter>
</>
)}
/>
<>
<Controller
name="defaultValue"
control={control}
defaultValue={initialDefaultValue}
render={() => <></>}
/>
<Controller
name="options"
control={control}
defaultValue={initialOptions}
render={({ field: { onChange, value: options } }) => (
<>
<StyledContainer>
<StyledLabel>Options</StyledLabel>
<DraggableList
onDragEnd={(result) => handleDragEnd(options, result, onChange)}
draggableItems={
<>
{options.map((option, index) => (
<DraggableItem
key={option.id}
draggableId={option.id}
index={index}
isDragDisabled={options.length === 1}
itemComponent={
<SettingsObjectFieldSelectFormOptionRow
key={option.id}
option={option}
onChange={(nextOption) => {
const nextOptions = [...options];
nextOptions.splice(index, 1, nextOption);
onChange(nextOptions);
// Update option value in defaultValue if value has changed
if (
nextOption.value !== option.value &&
isOptionDefaultValue(option.value)
) {
handleRemoveOptionAsDefault(option.value);
handleSetOptionAsDefault(nextOption.value);
}
}}
onRemove={
options.length > 1
? () => {
const nextOptions = [...options];
nextOptions.splice(index, 1);
onChange(nextOptions);
}
: undefined
}
isDefault={isOptionDefaultValue(option.value)}
onSetAsDefault={() =>
handleSetOptionAsDefault(option.value)
}
onRemoveAsDefault={() =>
handleRemoveOptionAsDefault(option.value)
}
/>
}
/>
))}
</>
}
/>
</StyledContainer>
<StyledFooter>
<StyledButton
title="Add option"
Icon={IconPlus}
onClick={() =>
onChange([...options, generateNewSelectOption(options)])
}
/>
</StyledFooter>
</>
)}
/>
</>
);
};

View File

@ -10,6 +10,8 @@ import {
} from 'twenty-ui';
import { v4 } from 'uuid';
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { getOptionValueFromLabel } from '@/settings/data-model/fields/forms/utils/getOptionValueFromLabel';
import { ColorSample } from '@/ui/display/color/components/ColorSample';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { TextInput } from '@/ui/input/components/TextInput';
@ -21,14 +23,14 @@ import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemSelectColor } from '@/ui/navigation/menu-item/components/MenuItemSelectColor';
import { MAIN_COLOR_NAMES } from '@/ui/theme/constants/MainColorNames';
import { SettingsObjectFieldSelectFormOption } from '../types/SettingsObjectFieldSelectFormOption';
type SettingsObjectFieldSelectFormOptionRowProps = {
className?: string;
isDefault?: boolean;
onChange: (value: SettingsObjectFieldSelectFormOption) => void;
onChange: (value: FieldMetadataItemOption) => void;
onRemove?: () => void;
option: SettingsObjectFieldSelectFormOption;
onSetAsDefault?: () => void;
onRemoveAsDefault?: () => void;
option: FieldMetadataItemOption;
};
const StyledRow = styled.div`
@ -58,6 +60,8 @@ export const SettingsObjectFieldSelectFormOptionRow = ({
isDefault,
onChange,
onRemove,
onSetAsDefault,
onRemoveAsDefault,
option,
}: SettingsObjectFieldSelectFormOptionRowProps) => {
const theme = useTheme();
@ -106,7 +110,9 @@ export const SettingsObjectFieldSelectFormOptionRow = ({
/>
<StyledOptionInput
value={option.label}
onChange={(label) => onChange({ ...option, label })}
onChange={(label) =>
onChange({ ...option, label, value: getOptionValueFromLabel(label) })
}
RightIcon={isDefault ? IconCheck : undefined}
/>
<Dropdown
@ -124,7 +130,7 @@ export const SettingsObjectFieldSelectFormOptionRow = ({
LeftIcon={IconX}
text="Remove as default"
onClick={() => {
onChange({ ...option, isDefault: false });
onRemoveAsDefault?.();
closeActionsDropdown();
}}
/>
@ -133,7 +139,7 @@ export const SettingsObjectFieldSelectFormOptionRow = ({
LeftIcon={IconCheck}
text="Set as default"
onClick={() => {
onChange({ ...option, isDefault: true });
onSetAsDefault?.();
closeActionsDropdown();
}}
/>