refactor: use react-hook-form for Field type config forms (#5326)

Closes #4295

Note: for the sake of an easier code review, I did not rename/move some
files and added "todo" comments instead so Github is able to match those
files with their previous version.
This commit is contained in:
Thaïs
2024-05-07 21:07:56 +02:00
committed by GitHub
parent b7a2e72c32
commit bb995d5488
34 changed files with 714 additions and 1068 deletions

View File

@ -1,17 +1,26 @@
import { Controller, useFormContext } from 'react-hook-form';
import styled from '@emotion/styled';
import { IconCheck, IconX } from 'twenty-ui';
import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { Select } from '@/ui/input/components/Select';
import { CardContent } from '@/ui/layout/card/components/CardContent';
type SettingsDataModelDefaultValueFormProps = {
className?: string;
disabled?: boolean;
onChange?: (defaultValue: SettingsDataModelDefaultValue) => void;
value?: SettingsDataModelDefaultValue;
};
// TODO: rename to SettingsDataModelFieldBooleanForm and move to settings/data-model/fields/forms/components
export type SettingsDataModelDefaultValue = any;
export const settingsDataModelFieldBooleanFormSchema = z.object({
defaultValue: z.boolean(),
});
type SettingsDataModelFieldBooleanFormValues = z.infer<
typeof settingsDataModelFieldBooleanFormSchema
>;
type SettingsDataModelFieldBooleanFormProps = {
className?: string;
fieldMetadataItem?: Pick<FieldMetadataItem, 'defaultValue'>;
};
const StyledContainer = styled(CardContent)`
padding-bottom: ${({ theme }) => theme.spacing(3.5)};
@ -26,34 +35,42 @@ const StyledLabel = styled.span`
margin-top: ${({ theme }) => theme.spacing(1)};
`;
export const SettingsDataModelDefaultValueForm = ({
export const SettingsDataModelFieldBooleanForm = ({
className,
disabled,
onChange,
value,
}: SettingsDataModelDefaultValueFormProps) => {
fieldMetadataItem,
}: SettingsDataModelFieldBooleanFormProps) => {
const { control } = useFormContext<SettingsDataModelFieldBooleanFormValues>();
const initialValue = fieldMetadataItem?.defaultValue ?? true;
return (
<StyledContainer>
<StyledLabel>Default Value</StyledLabel>
<Select
className={className}
fullWidth
disabled={disabled}
dropdownId="object-field-default-value-select"
value={value}
onChange={(value) => onChange?.(value)}
options={[
{
value: true,
label: 'True',
Icon: IconCheck,
},
{
value: false,
label: 'False',
Icon: IconX,
},
]}
<Controller
name="defaultValue"
control={control}
defaultValue={initialValue}
render={({ field: { onChange, value } }) => (
<Select
className={className}
fullWidth
dropdownId="object-field-default-value-select"
value={value}
onChange={onChange}
options={[
{
value: true,
label: 'True',
Icon: IconCheck,
},
{
value: false,
label: 'False',
Icon: IconX,
},
]}
/>
)}
/>
</StyledContainer>
);

View File

@ -1,39 +1,66 @@
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';
import { SETTINGS_FIELD_CURRENCY_CODES } from '../constants/SettingsFieldCurrencyCodes';
// TODO: rename to SettingsDataModelFieldCurrencyForm and move to settings/data-model/fields/forms/components
export type SettingsObjectFieldCurrencyFormValues = {
currencyCode: CurrencyCode;
};
export const settingsDataModelFieldCurrencyFormSchema = z.object({
defaultValue: z.object({
currencyCode: z.nativeEnum(CurrencyCode),
}),
});
type SettingsObjectFieldCurrencyFormProps = {
type SettingsDataModelFieldCurrencyFormValues = z.infer<
typeof settingsDataModelFieldCurrencyFormSchema
>;
type SettingsDataModelFieldCurrencyFormProps = {
disabled?: boolean;
onChange: (values: Partial<SettingsObjectFieldCurrencyFormValues>) => void;
values: SettingsObjectFieldCurrencyFormValues;
fieldMetadataItem?: Pick<FieldMetadataItem, 'defaultValue'>;
};
export const SettingsObjectFieldCurrencyForm = ({
disabled,
onChange,
values,
}: SettingsObjectFieldCurrencyFormProps) => (
<CardContent>
<Select
fullWidth
disabled={disabled}
label="Default Unit"
dropdownId="currency-unit-select"
value={values.currencyCode}
options={Object.entries(SETTINGS_FIELD_CURRENCY_CODES).map(
([value, { label, Icon }]) => ({
label,
value: value as CurrencyCode,
Icon,
}),
)}
onChange={(value) => onChange({ currencyCode: value })}
/>
</CardContent>
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

@ -1,28 +1,46 @@
import { useMemo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import styled from '@emotion/styled';
import { useIcons } from 'twenty-ui';
import { z } from 'zod';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isObjectMetadataAvailableForRelation } from '@/object-metadata/utils/isObjectMetadataAvailableForRelation';
import { validateMetadataLabel } from '@/object-metadata/utils/validateMetadataLabel';
import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
import { Field } from '~/generated-metadata/graphql';
import { RelationMetadataType } from '~/generated-metadata/graphql';
import { RELATION_TYPES } from '../constants/RelationTypes';
import { RelationType } from '../types/RelationType';
export type SettingsObjectFieldRelationFormValues = {
field: Pick<Field, 'icon' | 'label'>;
objectMetadataId: string;
type: RelationType;
};
// TODO: rename to SettingsDataModelFieldRelationForm and move to settings/data-model/fields/forms/components
type SettingsObjectFieldRelationFormProps = {
disableFieldEdition?: boolean;
disableRelationEdition?: boolean;
onChange: (values: Partial<SettingsObjectFieldRelationFormValues>) => void;
values: SettingsObjectFieldRelationFormValues;
export const settingsDataModelFieldRelationFormSchema = z.object({
relation: z.object({
field: fieldMetadataItemSchema.pick({
icon: true,
label: true,
}),
objectMetadataId: z.string().uuid(),
type: z.enum(
Object.keys(RELATION_TYPES) as [RelationType, ...RelationType[]],
),
}),
});
type SettingsDataModelFieldRelationFormValues = z.infer<
typeof settingsDataModelFieldRelationFormSchema
>;
type SettingsDataModelFieldRelationFormProps = {
fieldMetadataItem?: Pick<
FieldMetadataItem,
'fromRelationMetadata' | 'toRelationMetadata' | 'type'
>;
};
const StyledContainer = styled.div`
@ -50,84 +68,119 @@ const StyledInputsContainer = styled.div`
width: 100%;
`;
export const SettingsObjectFieldRelationForm = ({
disableFieldEdition,
disableRelationEdition,
onChange,
values,
}: SettingsObjectFieldRelationFormProps) => {
const RELATION_TYPE_OPTIONS = Object.entries(RELATION_TYPES)
.filter(([value]) => 'ONE_TO_ONE' !== value)
.map(([value, { label, Icon }]) => ({
label,
value: value as RelationType,
Icon,
}));
export const SettingsDataModelFieldRelationForm = ({
fieldMetadataItem,
}: SettingsDataModelFieldRelationFormProps) => {
const { control } =
useFormContext<SettingsDataModelFieldRelationFormValues>();
const { getIcon } = useIcons();
const { objectMetadataItems, findObjectMetadataItemById } =
useFilteredObjectMetadataItems();
const { objectMetadataItems } = useFilteredObjectMetadataItems();
const getRelationMetadata = useGetRelationMetadata();
const {
relationFieldMetadataItem,
relationType,
relationObjectMetadataItem,
} =
useMemo(
() =>
fieldMetadataItem ? getRelationMetadata({ fieldMetadataItem }) : null,
[fieldMetadataItem, getRelationMetadata],
) ?? {};
const disableFieldEdition =
relationFieldMetadataItem && !relationFieldMetadataItem.isCustom;
const disableRelationEdition = !!relationFieldMetadataItem;
const selectedObjectMetadataItem =
(values.objectMetadataId
? findObjectMetadataItemById(values.objectMetadataId)
: undefined) || objectMetadataItems[0];
relationObjectMetadataItem ?? objectMetadataItems[0];
return (
<StyledContainer>
<StyledSelectsContainer>
<Select
label="Relation type"
dropdownId="relation-type-select"
fullWidth
disabled={disableRelationEdition}
value={values.type}
options={Object.entries(RELATION_TYPES)
.filter(([value]) => 'ONE_TO_ONE' !== value)
.map(([value, { label, Icon }]) => ({
label,
value: value as RelationType,
Icon,
}))}
onChange={(value) => onChange({ type: value })}
<Controller
name="relation.type"
control={control}
defaultValue={relationType ?? RelationMetadataType.OneToMany}
render={({ field: { onChange, value } }) => (
<Select
label="Relation type"
dropdownId="relation-type-select"
fullWidth
disabled={disableRelationEdition}
value={value}
options={RELATION_TYPE_OPTIONS}
onChange={onChange}
/>
)}
/>
<Select
label="Object destination"
dropdownId="object-destination-select"
fullWidth
disabled={disableRelationEdition}
value={values.objectMetadataId}
options={objectMetadataItems
.filter((objectMetadataItem) =>
isObjectMetadataAvailableForRelation(objectMetadataItem),
)
.map((objectMetadataItem) => ({
label: objectMetadataItem.labelPlural,
value: objectMetadataItem.id,
Icon: getIcon(objectMetadataItem.icon),
}))}
onChange={(value) => onChange({ objectMetadataId: value })}
<Controller
name="relation.objectMetadataId"
control={control}
defaultValue={selectedObjectMetadataItem?.id}
render={({ field: { onChange, value } }) => (
<Select
label="Object destination"
dropdownId="object-destination-select"
fullWidth
disabled={disableRelationEdition}
value={value}
options={objectMetadataItems
.filter(isObjectMetadataAvailableForRelation)
.map((objectMetadataItem) => ({
label: objectMetadataItem.labelPlural,
value: objectMetadataItem.id,
Icon: getIcon(objectMetadataItem.icon),
}))}
onChange={onChange}
/>
)}
/>
</StyledSelectsContainer>
<StyledInputsLabel>
Field on {selectedObjectMetadataItem?.labelPlural}
</StyledInputsLabel>
<StyledInputsContainer>
<IconPicker
disabled={disableFieldEdition}
dropdownId="field-destination-icon-picker"
selectedIconKey={values.field.icon || undefined}
onChange={(value) =>
onChange({
field: { ...values.field, icon: value.iconKey },
})
<Controller
name="relation.field.icon"
control={control}
defaultValue={
relationFieldMetadataItem?.icon ??
relationObjectMetadataItem?.icon ??
'IconUsers'
}
variant="primary"
render={({ field: { onChange, value } }) => (
<IconPicker
disabled={disableFieldEdition}
dropdownId="field-destination-icon-picker"
selectedIconKey={value ?? undefined}
onChange={({ iconKey }) => onChange(iconKey)}
variant="primary"
/>
)}
/>
<TextInput
disabled={disableFieldEdition}
placeholder="Field name"
value={values.field.label}
onChange={(value) => {
if (!value || validateMetadataLabel(value)) {
onChange({
field: { ...values.field, label: value },
});
}
}}
fullWidth
<Controller
name="relation.field.label"
control={control}
defaultValue={relationFieldMetadataItem?.label}
render={({ field: { onChange, value } }) => (
<TextInput
disabled={disableFieldEdition}
placeholder="Field name"
value={value}
onChange={onChange}
fullWidth
/>
)}
/>
</StyledInputsContainer>
</StyledContainer>

View File

@ -1,8 +1,12 @@
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 { LightButton } from '@/ui/input/button/components/LightButton';
import { CardContent } from '@/ui/layout/card/components/CardContent';
import { CardFooter } from '@/ui/layout/card/components/CardFooter';
@ -12,18 +16,32 @@ import {
MAIN_COLOR_NAMES,
ThemeColor,
} from '@/ui/theme/constants/MainColorNames';
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
import { moveArrayItem } from '~/utils/array/moveArrayItem';
import { SettingsObjectFieldSelectFormOption } from '../types/SettingsObjectFieldSelectFormOption';
import { SettingsObjectFieldSelectFormOptionRow } from './SettingsObjectFieldSelectFormOptionRow';
export type SettingsObjectFieldSelectFormValues =
SettingsObjectFieldSelectFormOption[];
// TODO: rename to SettingsDataModelFieldSelectForm and move to settings/data-model/fields/forms/components
type SettingsObjectFieldSelectFormProps = {
onChange: (values: SettingsObjectFieldSelectFormValues) => void;
values: SettingsObjectFieldSelectFormValues;
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),
});
export type SettingsDataModelFieldSelectFormValues = z.infer<
typeof settingsDataModelFieldSelectFormSchema
>;
type SettingsDataModelFieldSelectFormProps = {
fieldMetadataItem?: Pick<FieldMetadataItem, 'defaultValue' | 'options'>;
isMultiSelect?: boolean;
};
@ -58,12 +76,55 @@ const getNextColor = (currentColor: ThemeColor) => {
return MAIN_COLOR_NAMES[nextColorIndex];
};
export const SettingsObjectFieldSelectForm = ({
onChange,
values,
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,
}: SettingsObjectFieldSelectFormProps) => {
const handleDragEnd = (result: DropResult) => {
}: SettingsDataModelFieldSelectFormProps) => {
const { control } = useFormContext<SettingsDataModelFieldSelectFormValues>();
const initialDefaultValueOptionIndexes =
getDefaultValueOptionIndexes(fieldMetadataItem);
const initialValue = fieldMetadataItem?.options
?.map((option, index) => ({
...option,
isDefault: initialDefaultValueOptionIndexes?.includes(index),
}))
.sort((optionA, optionB) => optionA.position - optionB.position);
const handleDragEnd = (
values: SettingsObjectFieldSelectFormOption[],
result: DropResult,
onChange: (options: SettingsObjectFieldSelectFormOption[]) => void,
) => {
if (!result.destination) return;
const nextOptions = moveArrayItem(values, {
@ -74,27 +135,7 @@ export const SettingsObjectFieldSelectForm = ({
onChange(nextOptions);
};
const handleDefaultValueChange = (
index: number,
option: SettingsObjectFieldSelectFormOption,
nextOption: SettingsObjectFieldSelectFormOption,
forceUniqueDefaultValue: boolean,
) => {
const computeUniqueDefaultValue =
forceUniqueDefaultValue && option.isDefault !== nextOption.isDefault;
const nextOptions = computeUniqueDefaultValue
? values.map((value) => ({
...value,
isDefault: false,
}))
: [...values];
nextOptions.splice(index, 1, nextOption);
onChange(nextOptions);
};
const findNewLabel = () => {
const findNewLabel = (values: SettingsObjectFieldSelectFormOption[]) => {
let optionIndex = values.length + 1;
while (optionIndex < 100) {
const newLabel = `Option ${optionIndex}`;
@ -107,65 +148,75 @@ export const SettingsObjectFieldSelectForm = ({
};
return (
<>
<StyledContainer>
<StyledLabel>Options</StyledLabel>
<DraggableList
onDragEnd={handleDragEnd}
draggableItems={
<>
{values.map((option, index) => (
<DraggableItem
key={option.value}
draggableId={option.value}
index={index}
isDragDisabled={values.length === 1}
itemComponent={
<SettingsObjectFieldSelectFormOptionRow
<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}
isDefault={option.isDefault}
onChange={(nextOption) => {
handleDefaultValueChange(
index,
option,
nextOption,
!isMultiSelect,
);
}}
onRemove={
values.length > 1
? () => {
const nextOptions = [...values];
nextOptions.splice(index, 1);
onChange(nextOptions);
}
: undefined
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}
/>
}
option={option}
/>
}
/>
))}
</>
}
/>
</StyledContainer>
<StyledFooter>
<StyledButton
title="Add option"
Icon={IconPlus}
onClick={() =>
onChange([
...values,
{
color: getNextColor(values[values.length - 1].color),
label: findNewLabel(),
value: v4(),
},
])
}
/>
</StyledFooter>
</>
))}
</>
}
/>
</StyledContainer>
<StyledFooter>
<StyledButton
title="Add option"
Icon={IconPlus}
onClick={() =>
onChange([
...options,
{
color: getNextColor(options[options.length - 1].color),
label: findNewLabel(options),
value: v4(),
},
])
}
/>
</StyledFooter>
</>
)}
/>
);
};