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:
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user