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

@ -1033,7 +1033,9 @@ export type User = {
id: Scalars['UUID']['output'];
lastName: Scalars['String']['output'];
passwordHash?: Maybe<Scalars['String']['output']>;
/** @deprecated field migrated into the AppTokens Table ref: https://github.com/twentyhq/twenty/issues/5021 */
passwordResetToken?: Maybe<Scalars['String']['output']>;
/** @deprecated field migrated into the AppTokens Table ref: https://github.com/twentyhq/twenty/issues/5021 */
passwordResetTokenExpiresAt?: Maybe<Scalars['DateTime']['output']>;
supportUserHash?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];

View File

@ -69,18 +69,7 @@ export const variables = {
disableMetadataField: {
idToUpdate: fieldId,
updatePayload: { isActive: false, label: undefined },
},
editMetadataField: {
idToUpdate: '2c43466a-fe9e-4005-8d08-c5836067aa6c',
updatePayload: {
defaultValue: undefined,
description: null,
icon: undefined,
label: 'New label',
name: 'newLabel',
options: undefined,
},
},
}
};
const defaultResponseData = {

View File

@ -68,17 +68,6 @@ const mocks = [
},
})),
},
{
request: {
query: queries.activateMetadataField,
variables: variables.editMetadataField,
},
result: jest.fn(() => ({
data: {
updateOneField: responseData.default,
},
})),
},
];
const Wrapper = ({ children }: { children: ReactNode }) => (
@ -149,22 +138,4 @@ describe('useFieldMetadataItem', () => {
});
});
});
it('should editMetadataField', async () => {
const { result } = renderHook(() => useFieldMetadataItem(), {
wrapper: Wrapper,
});
await act(async () => {
const res = await result.current.editMetadataField({
id: fieldMetadataItem.id,
label: 'New label',
type: FieldMetadataType.Text,
});
expect(res.data).toEqual({
updateOneField: responseData.default,
});
});
});
});

View File

@ -1,7 +1,3 @@
import { v4 } from 'uuid';
import { FieldMetadataOption } from '@/object-metadata/types/FieldMetadataOption';
import { getDefaultValueForBackend } from '@/object-metadata/utils/getDefaultValueForBackend';
import { Field } from '~/generated/graphql';
import { FieldMetadataItem } from '../types/FieldMetadataItem';
@ -26,49 +22,12 @@ export const useFieldMetadataItem = () => {
) => {
const formattedInput = formatFieldMetadataItemInput(input);
const defaultValue = getDefaultValueForBackend(
input.defaultValue ?? formattedInput.defaultValue,
input.type,
);
return createOneFieldMetadataItem({
...formattedInput,
defaultValue,
objectMetadataId: input.objectMetadataId,
type: input.type,
});
};
const editMetadataField = (
input: Pick<
Field,
| 'id'
| 'label'
| 'icon'
| 'description'
| 'defaultValue'
| 'type'
| 'options'
>,
) => {
// In Edit mode, all options need an id,
// so we generate an id for newly created options.
const inputOptions = input.options?.map((option: FieldMetadataOption) =>
option.id ? option : { ...option, id: v4() },
);
const formattedInput = formatFieldMetadataItemInput({
...input,
options: inputOptions,
});
const defaultValue = input.defaultValue ?? formattedInput.defaultValue;
return updateOneFieldMetadataItem({
fieldMetadataIdToUpdate: input.id,
updatePayload: {
...formattedInput,
defaultValue,
},
label: formattedInput.label ?? '',
name: formattedInput.name ?? '',
});
};
@ -92,6 +51,5 @@ export const useFieldMetadataItem = () => {
createMetadataField,
disableMetadataField,
eraseMetadataField,
editMetadataField,
};
};

View File

@ -12,7 +12,14 @@ import { FieldMetadataItem } from '../types/FieldMetadataItem';
export const useGetRelationMetadata = () =>
useRecoilCallback(
({ snapshot }) =>
({ fieldMetadataItem }: { fieldMetadataItem: FieldMetadataItem }) => {
({
fieldMetadataItem,
}: {
fieldMetadataItem: Pick<
FieldMetadataItem,
'fromRelationMetadata' | 'toRelationMetadata' | 'type'
>;
}) => {
if (fieldMetadataItem.type !== FieldMetadataType.Relation) return null;
const relationMetadata =

View File

@ -77,7 +77,7 @@ describe('formatFieldMetadataItemInput', () => {
value: 'OPTION_2',
},
],
defaultValue: 'OPTION_1',
defaultValue: "'OPTION_1'",
};
const result = formatFieldMetadataItemInput(input);
@ -140,7 +140,7 @@ describe('formatFieldMetadataItemInput', () => {
value: 'OPTION_2',
},
],
defaultValue: ['OPTION_1', 'OPTION_2'],
defaultValue: ["'OPTION_1'", "'OPTION_2'"],
};
const result = formatFieldMetadataItemInput(input);

View File

@ -1,8 +0,0 @@
import { validateMetadataLabel } from '@/object-metadata/utils/validateMetadataLabel';
describe('validateMetadataLabel', () => {
it('should work as expected', () => {
const res = validateMetadataLabel('Pipeline Step');
expect(res).toBe(true);
});
});

View File

@ -1,6 +1,8 @@
import toSnakeCase from 'lodash.snakecase';
import { Field, FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getDefaultValueForBackend } from '@/object-metadata/utils/getDefaultValueForBackend';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { formatMetadataLabelToMetadataNameOrThrows } from '~/pages/settings/data-model/utils/format-metadata-label-to-name.util';
import { isDefined } from '~/utils/isDefined';
@ -21,25 +23,24 @@ export const getOptionValueFromLabel = (label: string) => {
};
export const formatFieldMetadataItemInput = (
input: Pick<
Field,
'label' | 'icon' | 'description' | 'defaultValue' | 'options'
> & { type?: FieldMetadataType },
input: Partial<
Pick<
FieldMetadataItem,
'type' | 'label' | 'defaultValue' | 'icon' | 'description'
>
> & { options?: FieldMetadataOption[] },
) => {
const options = input.options as FieldMetadataOption[];
const options = input.options as FieldMetadataOption[] | undefined;
let defaultValue = input.defaultValue;
if (input.type === FieldMetadataType.MultiSelect) {
const defaultOptions = options?.filter((option) => option.isDefault);
if (isDefined(defaultOptions)) {
defaultValue = defaultOptions.map(
(defaultOption) => `${getOptionValueFromLabel(defaultOption.label)}`,
);
}
defaultValue = options
?.filter((option) => option.isDefault)
?.map((defaultOption) => getOptionValueFromLabel(defaultOption.label));
}
if (input.type === FieldMetadataType.Select) {
const defaultOption = options?.find((option) => option.isDefault);
defaultValue = isDefined(defaultOption)
? `${getOptionValueFromLabel(defaultOption.label)}`
? getOptionValueFromLabel(defaultOption.label)
: undefined;
}
@ -59,12 +60,17 @@ export const formatFieldMetadataItemInput = (
}
}
const label = input.label?.trim();
return {
defaultValue,
defaultValue:
isDefined(defaultValue) && input.type
? getDefaultValueForBackend(defaultValue, input.type)
: undefined,
description: input.description?.trim() ?? null,
icon: input.icon,
label: input.label.trim(),
name: formatMetadataLabelToMetadataNameOrThrows(input.label.trim()),
label,
name: label ? formatMetadataLabelToMetadataNameOrThrows(label) : undefined,
options: options?.map((option, index) => ({
color: option.color,
id: option.id,

View File

@ -35,14 +35,14 @@ export const formatRelationMetadataInput = (
const {
description: fromDescription,
icon: fromIcon,
label: fromLabel,
name: fromName,
label: fromLabel = '',
name: fromName = '',
} = formatFieldMetadataItemInput(fromField);
const {
description: toDescription,
icon: toIcon,
label: toLabel,
name: toName,
label: toLabel = '',
name: toName = '',
} = formatFieldMetadataItemInput(toField);
return {

View File

@ -13,7 +13,7 @@ export const getDefaultValueForBackend = (
currencyCode: `'${currencyDefaultValue.currencyCode}'` as CurrencyCode,
} satisfies FieldCurrencyValue;
} else if (fieldMetadataType === FieldMetadataType.Select) {
return `'${defaultValue}'`;
return defaultValue ? `'${defaultValue}'` : null;
} else if (fieldMetadataType === FieldMetadataType.MultiSelect) {
return defaultValue.map((value: string) => `'${value}'`);
}

View File

@ -1,4 +0,0 @@
const metadataLabelValidationPattern = /^[^0-9].*$/;
export const validateMetadataLabel = (value: string) =>
!!value.match(metadataLabelValidationPattern);

View File

@ -14,7 +14,7 @@ export const MultiSelectFieldDisplay = ({
const { fieldValues, fieldDefinition } = useMultiSelectField();
const selectedOptions = fieldValues
? fieldDefinition.metadata.options.filter((option) =>
? fieldDefinition.metadata.options?.filter((option) =>
fieldValues.includes(option.value),
)
: [];

View File

@ -6,7 +6,11 @@ import { useRelationField } from '../../hooks/useRelationField';
export const RelationFieldDisplay = () => {
const { fieldValue, fieldDefinition, maxWidth } = useRelationField();
if (!fieldValue || !fieldDefinition) return null;
if (
!fieldValue ||
!fieldDefinition?.metadata.relationObjectMetadataNameSingular
)
return null;
return (
<RecordChip

View File

@ -5,7 +5,7 @@ import { useSelectField } from '../../hooks/useSelectField';
export const SelectFieldDisplay = () => {
const { fieldValue, fieldDefinition } = useSelectField();
const selectedOption = fieldDefinition.metadata.options.find(
const selectedOption = fieldDefinition.metadata.options?.find(
(option) => option.value === fieldValue,
);

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,22 +35,28 @@ 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>
<Controller
name="defaultValue"
control={control}
defaultValue={initialValue}
render={({ field: { onChange, value } }) => (
<Select
className={className}
fullWidth
disabled={disabled}
dropdownId="object-field-default-value-select"
value={value}
onChange={(value) => onChange?.(value)}
onChange={onChange}
options={[
{
value: true,
@ -55,6 +70,8 @@ export const SettingsDataModelDefaultValueForm = ({
},
]}
/>
)}
/>
</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(
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}
/>
)}
onChange={(value) => onChange({ currencyCode: value })}
/>
</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,85 +68,120 @@ const StyledInputsContainer = styled.div`
width: 100%;
`;
export const SettingsObjectFieldRelationForm = ({
disableFieldEdition,
disableRelationEdition,
onChange,
values,
}: SettingsObjectFieldRelationFormProps) => {
const { getIcon } = useIcons();
const { objectMetadataItems, findObjectMetadataItemById } =
useFilteredObjectMetadataItems();
const selectedObjectMetadataItem =
(values.objectMetadataId
? findObjectMetadataItemById(values.objectMetadataId)
: undefined) || objectMetadataItems[0];
return (
<StyledContainer>
<StyledSelectsContainer>
<Select
label="Relation type"
dropdownId="relation-type-select"
fullWidth
disabled={disableRelationEdition}
value={values.type}
options={Object.entries(RELATION_TYPES)
const RELATION_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 })}
}));
export const SettingsDataModelFieldRelationForm = ({
fieldMetadataItem,
}: SettingsDataModelFieldRelationFormProps) => {
const { control } =
useFormContext<SettingsDataModelFieldRelationFormValues>();
const { getIcon } = useIcons();
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 =
relationObjectMetadataItem ?? objectMetadataItems[0];
return (
<StyledContainer>
<StyledSelectsContainer>
<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}
/>
)}
/>
<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={values.objectMetadataId}
value={value}
options={objectMetadataItems
.filter((objectMetadataItem) =>
isObjectMetadataAvailableForRelation(objectMetadataItem),
)
.filter(isObjectMetadataAvailableForRelation)
.map((objectMetadataItem) => ({
label: objectMetadataItem.labelPlural,
value: objectMetadataItem.id,
Icon: getIcon(objectMetadataItem.icon),
}))}
onChange={(value) => onChange({ objectMetadataId: value })}
onChange={onChange}
/>
)}
/>
</StyledSelectsContainer>
<StyledInputsLabel>
Field on {selectedObjectMetadataItem?.labelPlural}
</StyledInputsLabel>
<StyledInputsContainer>
<Controller
name="relation.field.icon"
control={control}
defaultValue={
relationFieldMetadataItem?.icon ??
relationObjectMetadataItem?.icon ??
'IconUsers'
}
render={({ field: { onChange, value } }) => (
<IconPicker
disabled={disableFieldEdition}
dropdownId="field-destination-icon-picker"
selectedIconKey={values.field.icon || undefined}
onChange={(value) =>
onChange({
field: { ...values.field, icon: value.iconKey },
})
}
selectedIconKey={value ?? undefined}
onChange={({ iconKey }) => onChange(iconKey)}
variant="primary"
/>
)}
/>
<Controller
name="relation.field.label"
control={control}
defaultValue={relationFieldMetadataItem?.label}
render={({ field: { onChange, value } }) => (
<TextInput
disabled={disableFieldEdition}
placeholder="Field name"
value={values.field.label}
onChange={(value) => {
if (!value || validateMetadataLabel(value)) {
onChange({
field: { ...values.field, label: value },
});
}
}}
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,35 +148,43 @@ export const SettingsObjectFieldSelectForm = ({
};
return (
<Controller
name="options"
control={control}
defaultValue={initialValue?.length ? initialValue : [DEFAULT_OPTION]}
render={({ field: { onChange, value: options } }) => (
<>
<StyledContainer>
<StyledLabel>Options</StyledLabel>
<DraggableList
onDragEnd={handleDragEnd}
onDragEnd={(result) => handleDragEnd(options, result, onChange)}
draggableItems={
<>
{values.map((option, index) => (
{options.map((option, index) => (
<DraggableItem
key={option.value}
draggableId={option.value}
index={index}
isDragDisabled={values.length === 1}
isDragDisabled={options.length === 1}
itemComponent={
<SettingsObjectFieldSelectFormOptionRow
key={option.value}
isDefault={option.isDefault}
onChange={(nextOption) => {
handleDefaultValueChange(
index,
option,
nextOption,
!isMultiSelect,
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={
values.length > 1
options.length > 1
? () => {
const nextOptions = [...values];
const nextOptions = [...options];
nextOptions.splice(index, 1);
onChange(nextOptions);
}
@ -156,10 +205,10 @@ export const SettingsObjectFieldSelectForm = ({
Icon={IconPlus}
onClick={() =>
onChange([
...values,
...options,
{
color: getNextColor(values[values.length - 1].color),
label: findNewLabel(),
color: getNextColor(options[options.length - 1].color),
label: findNewLabel(options),
value: v4(),
},
])
@ -167,5 +216,7 @@ export const SettingsObjectFieldSelectForm = ({
/>
</StyledFooter>
</>
)}
/>
);
};

View File

@ -1,20 +1,25 @@
import { useFormContext } from 'react-hook-form';
import styled from '@emotion/styled';
import { z } from 'zod';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelDefaultValueForm } from '@/settings/data-model/components/SettingsDataModelDefaultValue';
import {
SettingsDataModelFieldBooleanForm,
settingsDataModelFieldBooleanFormSchema,
} from '@/settings/data-model/components/SettingsDataModelDefaultValue';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import {
SettingsObjectFieldCurrencyForm,
SettingsObjectFieldCurrencyFormValues,
SettingsDataModelFieldCurrencyForm,
settingsDataModelFieldCurrencyFormSchema,
} from '@/settings/data-model/components/SettingsObjectFieldCurrencyForm';
import {
SettingsObjectFieldRelationForm,
SettingsObjectFieldRelationFormValues,
SettingsDataModelFieldRelationForm,
settingsDataModelFieldRelationFormSchema,
} from '@/settings/data-model/components/SettingsObjectFieldRelationForm';
import {
SettingsObjectFieldSelectForm,
SettingsObjectFieldSelectFormValues,
SettingsDataModelFieldSelectForm,
settingsDataModelFieldSelectFormSchema,
} from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
import {
@ -23,26 +28,44 @@ import {
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export type SettingsDataModelFieldSettingsFormValues = {
currency: SettingsObjectFieldCurrencyFormValues;
relation: SettingsObjectFieldRelationFormValues;
select: SettingsObjectFieldSelectFormValues;
multiSelect: SettingsObjectFieldSelectFormValues;
defaultValue: any;
};
const booleanFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.Boolean) })
.merge(settingsDataModelFieldBooleanFormSchema);
const currencyFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.Currency) })
.merge(settingsDataModelFieldCurrencyFormSchema);
const relationFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.Relation) })
.merge(settingsDataModelFieldRelationFormSchema);
const selectFieldFormSchema = z
.object({
type: z.enum([FieldMetadataType.Select, FieldMetadataType.MultiSelect]),
})
.merge(settingsDataModelFieldSelectFormSchema);
export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
'type',
[
booleanFieldFormSchema,
currencyFieldFormSchema,
relationFieldFormSchema,
selectFieldFormSchema,
],
);
type SettingsDataModelFieldSettingsFormValues = z.infer<
typeof settingsDataModelFieldSettingsFormSchema
>;
type SettingsDataModelFieldSettingsFormCardProps = {
disableCurrencyForm?: boolean;
onChange: (values: Partial<SettingsDataModelFieldSettingsFormValues>) => void;
relationFieldMetadataItem?: Pick<
FieldMetadataItem,
'id' | 'isCustom' | 'name'
>;
values: SettingsDataModelFieldSettingsFormValues;
} & Pick<
SettingsDataModelFieldPreviewCardProps,
'fieldMetadataItem' | 'objectMetadataItem'
>;
fieldMetadataItem: Pick<FieldMetadataItem, 'icon' | 'label' | 'type'> &
Partial<Omit<FieldMetadataItem, 'icon' | 'label' | 'type'>>;
relationFieldMetadataItem?: FieldMetadataItem;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
display: grid;
@ -81,18 +104,23 @@ export const SettingsDataModelFieldSettingsFormCard = ({
disableCurrencyForm,
fieldMetadataItem,
objectMetadataItem,
onChange,
relationFieldMetadataItem,
values,
}: SettingsDataModelFieldSettingsFormCardProps) => {
const { watch: watchFormValue } =
useFormContext<SettingsDataModelFieldSettingsFormValues>();
const { findObjectMetadataItemById } = useFilteredObjectMetadataItems();
if (!previewableTypes.includes(fieldMetadataItem.type)) return null;
const relationObjectMetadataItem = findObjectMetadataItemById(
values.relation.objectMetadataId,
);
const relationTypeConfig = RELATION_TYPES[values.relation.type];
const relationObjectMetadataId = watchFormValue('relation.objectMetadataId');
const relationObjectMetadataItem = relationObjectMetadataId
? findObjectMetadataItemById(relationObjectMetadataId)
: undefined;
const relationType = watchFormValue('relation.type');
const relationTypeConfig = relationType
? RELATION_TYPES[relationType]
: undefined;
return (
<SettingsDataModelPreviewFormCard
@ -103,14 +131,11 @@ export const SettingsDataModelFieldSettingsFormCard = ({
shrink={fieldMetadataItem.type === FieldMetadataType.Relation}
objectMetadataItem={objectMetadataItem}
relationObjectMetadataItem={relationObjectMetadataItem}
selectOptions={
fieldMetadataItem.type === FieldMetadataType.MultiSelect
? values.multiSelect
: values.select
}
selectOptions={watchFormValue('options')}
/>
{fieldMetadataItem.type === FieldMetadataType.Relation &&
!!relationObjectMetadataItem && (
!!relationObjectMetadataItem &&
!!relationTypeConfig && (
<>
<StyledRelationImage
src={relationTypeConfig.imageSrc}
@ -119,11 +144,11 @@ export const SettingsDataModelFieldSettingsFormCard = ({
/>
<StyledFieldPreviewCard
fieldMetadataItem={{
icon: values.relation.field.icon,
label: values.relation.field.label || 'Field name',
...relationFieldMetadataItem,
icon: watchFormValue('relation.field.icon'),
label:
watchFormValue('relation.field.label') || 'Field name',
type: FieldMetadataType.Relation,
name: relationFieldMetadataItem?.name,
id: relationFieldMetadataItem?.id,
}}
shrink
objectMetadataItem={relationObjectMetadataItem}
@ -134,49 +159,25 @@ export const SettingsDataModelFieldSettingsFormCard = ({
</StyledPreviewContent>
}
form={
fieldMetadataItem.type === FieldMetadataType.Currency ? (
<SettingsObjectFieldCurrencyForm
fieldMetadataItem.type === FieldMetadataType.Boolean ? (
<SettingsDataModelFieldBooleanForm
fieldMetadataItem={fieldMetadataItem}
/>
) : fieldMetadataItem.type === FieldMetadataType.Currency ? (
<SettingsDataModelFieldCurrencyForm
disabled={disableCurrencyForm}
values={values.currency}
onChange={(nextCurrencyValues) =>
onChange({
currency: { ...values.currency, ...nextCurrencyValues },
})
}
fieldMetadataItem={fieldMetadataItem}
/>
) : fieldMetadataItem.type === FieldMetadataType.Relation ? (
<SettingsObjectFieldRelationForm
disableFieldEdition={
relationFieldMetadataItem && !relationFieldMetadataItem.isCustom
}
disableRelationEdition={!!relationFieldMetadataItem}
values={values.relation}
onChange={(nextRelationValues) =>
onChange({
relation: { ...values.relation, ...nextRelationValues },
})
}
<SettingsDataModelFieldRelationForm
fieldMetadataItem={fieldMetadataItem}
/>
) : fieldMetadataItem.type === FieldMetadataType.Select ? (
<SettingsObjectFieldSelectForm
values={values.select}
onChange={(nextSelectValues) =>
onChange({ select: nextSelectValues })
}
/>
) : fieldMetadataItem.type === FieldMetadataType.MultiSelect ? (
<SettingsObjectFieldSelectForm
values={values.multiSelect}
onChange={(nextMultiSelectValues) =>
onChange({ multiSelect: nextMultiSelectValues })
}
isMultiSelect={true}
/>
) : fieldMetadataItem.type === FieldMetadataType.Boolean ? (
<SettingsDataModelDefaultValueForm
value={values.defaultValue}
onChange={(nextValueDefaultValue) =>
onChange({ defaultValue: nextValueDefaultValue })
) : fieldMetadataItem.type === FieldMetadataType.Select ||
fieldMetadataItem.type === FieldMetadataType.MultiSelect ? (
<SettingsDataModelFieldSelectForm
fieldMetadataItem={fieldMetadataItem}
isMultiSelect={
fieldMetadataItem.type === FieldMetadataType.MultiSelect
}
/>
) : undefined

View File

@ -1,38 +1,47 @@
import { Controller, useFormContext } from 'react-hook-form';
import omit from 'lodash.omit';
import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import {
SETTINGS_FIELD_TYPE_CONFIGS,
SettingsFieldTypeConfig,
} from '@/settings/data-model/constants/SettingsFieldTypeConfigs';
import { SettingsSupportedFieldType } from '@/settings/data-model/types/SettingsSupportedFieldType';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const settingsDataModelFieldTypeFormSchema = z.object({
type: z.enum(
Object.keys(SETTINGS_FIELD_TYPE_CONFIGS) as [
SettingsSupportedFieldType,
...SettingsSupportedFieldType[],
],
),
});
type SettingsDataModelFieldTypeFormValues = z.infer<
typeof settingsDataModelFieldTypeFormSchema
>;
type SettingsDataModelFieldTypeSelectProps = {
className?: string;
disabled?: boolean;
excludedFieldTypes?: SettingsSupportedFieldType[];
onChange?: ({
type,
defaultValue,
}: {
type: SettingsSupportedFieldType;
defaultValue: any;
}) => void;
value?: SettingsSupportedFieldType;
fieldMetadataItem?: FieldMetadataItem;
};
export const SettingsDataModelFieldTypeSelect = ({
className,
disabled,
excludedFieldTypes = [],
onChange,
value,
fieldMetadataItem,
}: SettingsDataModelFieldTypeSelectProps) => {
const fieldTypeConfigs = omit(
SETTINGS_FIELD_TYPE_CONFIGS,
excludedFieldTypes,
);
const { control } = useFormContext<SettingsDataModelFieldTypeFormValues>();
const fieldTypeConfigs: Partial<
Record<SettingsSupportedFieldType, SettingsFieldTypeConfig>
> = omit(SETTINGS_FIELD_TYPE_CONFIGS, excludedFieldTypes);
const fieldTypeOptions = Object.entries<SettingsFieldTypeConfig>(
fieldTypeConfigs,
).map<SelectOption<SettingsSupportedFieldType>>(([key, dataTypeConfig]) => ({
@ -42,19 +51,25 @@ export const SettingsDataModelFieldTypeSelect = ({
}));
return (
<Controller
name="type"
control={control}
defaultValue={
fieldMetadataItem && fieldMetadataItem.type in fieldTypeConfigs
? (fieldMetadataItem.type as SettingsSupportedFieldType)
: undefined
}
render={({ field: { onChange, value } }) => (
<Select
className={className}
fullWidth
disabled={disabled}
dropdownId="object-field-type-select"
value={value}
onChange={(value) =>
onChange?.({
type: value,
defaultValue: value === FieldMetadataType.Boolean ? false : undefined,
})
}
onChange={onChange}
options={fieldTypeOptions}
/>
)}
/>
);
};

View File

@ -1,12 +1,8 @@
import { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui';
import { fieldMetadataFormDefaultValues } from '@/settings/data-model/fields/forms/hooks/useFieldMetadataForm';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FormProviderDecorator } from '~/testing/decorators/FormProviderDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
@ -22,14 +18,6 @@ const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type === FieldMetadataType.Text,
)!;
const defaultValues = {
currency: fieldMetadataFormDefaultValues.currency,
relation: fieldMetadataFormDefaultValues.relation,
select: fieldMetadataFormDefaultValues.select,
multiSelect: fieldMetadataFormDefaultValues.multiSelect,
defaultValue: fieldMetadataFormDefaultValues.defaultValue,
};
const meta: Meta<typeof SettingsDataModelFieldSettingsFormCard> = {
title:
'Modules/Settings/DataModel/Fields/Forms/SettingsDataModelFieldSettingsFormCard',
@ -39,12 +27,11 @@ const meta: Meta<typeof SettingsDataModelFieldSettingsFormCard> = {
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
FormProviderDecorator,
],
args: {
fieldMetadataItem,
objectMetadataItem: mockedCompanyObjectMetadataItem,
onChange: fn(),
values: defaultValues,
},
parameters: {
container: { width: 512 },
@ -57,24 +44,14 @@ type Story = StoryObj<typeof SettingsDataModelFieldSettingsFormCard>;
export const Default: Story = {};
const relationFieldMetadataItem = mockedPersonObjectMetadataItem.fields.find(
({ name }) => name === 'company',
)!;
export const WithRelationForm: Story = {
args: {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ name }) => name === 'people',
),
relationFieldMetadataItem,
values: {
...defaultValues,
relation: {
field: relationFieldMetadataItem,
objectMetadataId: mockedPersonObjectMetadataItem.id,
type: RelationMetadataType.OneToMany,
},
},
relationFieldMetadataItem: mockedPersonObjectMetadataItem.fields.find(
({ name }) => name === 'company',
)!,
},
};
@ -85,34 +62,5 @@ export const WithSelectForm: Story = {
icon: 'IconBuildingFactory2',
type: FieldMetadataType.Select,
},
values: {
...defaultValues,
select: [
{
color: 'pink',
isDefault: true,
label: '💊 Health',
value: 'HEALTH',
},
{
color: 'purple',
label: '🏭 Industry',
value: 'INDUSTRY',
},
{ color: 'sky', label: '🤖 SaaS', value: 'SAAS' },
{
color: 'turquoise',
label: '🌿 Green tech',
value: 'GREEN_TECH',
},
{
color: 'yellow',
label: '🚲 Mobility',
value: 'MOBILITY',
},
{ color: 'green', label: '🌏 NGO', value: 'NGO' },
],
defaultValue: undefined,
},
},
};

View File

@ -1,5 +1,5 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { expect, userEvent, within } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@ -12,10 +12,6 @@ const meta: Meta<typeof SettingsDataModelFieldTypeSelect> = {
'Modules/Settings/DataModel/Fields/Forms/SettingsDataModelFieldTypeSelect',
component: SettingsDataModelFieldTypeSelect,
decorators: [ComponentDecorator],
args: {
onChange: fn(),
value: FieldMetadataType.Text,
},
parameters: {
container: { width: 512 },
msw: graphqlMocks,

View File

@ -1,55 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { FieldMetadataType } from '~/generated/graphql';
import { useFieldMetadataForm } from '../useFieldMetadataForm';
describe('useFieldMetadataForm', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useFieldMetadataForm());
expect(result.current.isInitialized).toBe(false);
act(() => {
result.current.initForm({});
});
expect(result.current.isInitialized).toBe(true);
expect(result.current.formValues).toEqual({
type: 'TEXT',
currency: { currencyCode: 'USD' },
relation: {
type: 'ONE_TO_MANY',
objectMetadataId: '',
field: { label: '' },
},
defaultValue: null,
select: [
{ color: 'green', label: 'Option 1', value: expect.any(String) },
],
multiSelect: [
{ color: 'green', label: 'Option 1', value: expect.any(String) },
],
});
});
it('should handle form changes', () => {
const { result } = renderHook(() => useFieldMetadataForm());
act(() => {
result.current.initForm({});
});
expect(result.current.hasFieldFormChanged).toBe(false);
expect(result.current.hasRelationFormChanged).toBe(false);
expect(result.current.hasSelectFormChanged).toBe(false);
act(() => {
result.current.handleFormChange({ type: FieldMetadataType.Number });
});
expect(result.current.hasFieldFormChanged).toBe(true);
expect(result.current.hasRelationFormChanged).toBe(false);
expect(result.current.hasSelectFormChanged).toBe(false);
});
});

View File

@ -1,262 +0,0 @@
import { useState } from 'react';
import { DeepPartial } from 'react-hook-form';
import { v4 } from 'uuid';
import { z } from 'zod';
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import { SettingsSupportedFieldType } from '@/settings/data-model/types/SettingsSupportedFieldType';
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { SettingsDataModelFieldSettingsFormValues } from '../components/SettingsDataModelFieldSettingsFormCard';
type FormValues = {
defaultValue: any;
type: SettingsSupportedFieldType;
} & SettingsDataModelFieldSettingsFormValues;
export const fieldMetadataFormDefaultValues: FormValues = {
type: FieldMetadataType.Text,
currency: { currencyCode: CurrencyCode.USD },
relation: {
type: RelationMetadataType.OneToMany,
objectMetadataId: '',
field: { label: '' },
},
defaultValue: null,
select: [{ color: 'green', label: 'Option 1', value: v4() }],
multiSelect: [{ color: 'green', label: 'Option 1', value: v4() }],
};
const relationTargetFieldSchema = z.object({
description: z.string().optional(),
icon: z.string().startsWith('Icon'),
label: z.string().min(1),
defaultValue: z.any(),
});
const fieldSchema = z.object({
defaultValue: z.any(),
type: z.enum(
Object.values(FieldMetadataType) as [
FieldMetadataType,
...FieldMetadataType[],
],
),
});
const currencySchema = fieldSchema.merge(
z.object({
type: z.literal(FieldMetadataType.Currency),
currency: z.object({
currencyCode: z.nativeEnum(CurrencyCode),
}),
}),
);
const relationSchema = fieldSchema.merge(
z.object({
type: z.literal(FieldMetadataType.Relation),
relation: z.object({
field: relationTargetFieldSchema,
objectMetadataId: z.string().uuid(),
type: z.enum([
RelationMetadataType.OneToMany,
RelationMetadataType.OneToOne,
'MANY_TO_ONE',
]),
}),
}),
);
const selectSchema = fieldSchema.merge(
z.object({
type: z.literal(FieldMetadataType.Select),
select: z
.array(
z.object({
color: themeColorSchema,
id: z.string().optional(),
isDefault: z.boolean().optional(),
label: z.string().min(1),
}),
)
.nonempty(),
}),
);
const multiSelectSchema = fieldSchema.merge(
z.object({
type: z.literal(FieldMetadataType.MultiSelect),
multiSelect: z
.array(
z.object({
color: themeColorSchema,
id: z.string().optional(),
isDefault: z.boolean().optional(),
label: z.string().min(1),
}),
)
.nonempty(),
}),
);
const {
Currency: _Currency,
Relation: _Relation,
Select: _Select,
MultiSelect: _MultiSelect,
...otherFieldTypes
} = FieldMetadataType;
type OtherFieldType = Exclude<
FieldMetadataType,
| FieldMetadataType.Currency
| FieldMetadataType.Relation
| FieldMetadataType.Select
| FieldMetadataType.MultiSelect
>;
const otherFieldTypesSchema = fieldSchema.merge(
z.object({
type: z.enum(
Object.values(otherFieldTypes) as [OtherFieldType, ...OtherFieldType[]],
),
}),
);
const schema = z.discriminatedUnion('type', [
currencySchema,
relationSchema,
selectSchema,
multiSelectSchema,
otherFieldTypesSchema,
]);
type PartialFormValues = Partial<Omit<FormValues, 'relation'>> &
DeepPartial<Pick<FormValues, 'relation'>>;
export const useFieldMetadataForm = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [initialFormValues, setInitialFormValues] = useState<FormValues>(
fieldMetadataFormDefaultValues,
);
const [formValues, setFormValues] = useState<FormValues>(
fieldMetadataFormDefaultValues,
);
const [hasFieldFormChanged, setHasFieldFormChanged] = useState(false);
const [hasCurrencyFormChanged, setHasCurrencyFormChanged] = useState(false);
const [hasRelationFormChanged, setHasRelationFormChanged] = useState(false);
const [hasSelectFormChanged, setHasSelectFormChanged] = useState(false);
const [hasMultiSelectFormChanged, setHasMultiSelectFormChanged] =
useState(false);
const [hasDefaultValueChanged, setHasDefaultValueFormChanged] =
useState(false);
const [validationResult, setValidationResult] = useState(
schema.safeParse(formValues),
);
const mergePartialValues = (
previousValues: FormValues,
nextValues: PartialFormValues,
): FormValues => ({
...previousValues,
...nextValues,
currency: { ...previousValues.currency, ...nextValues.currency },
relation: {
...previousValues.relation,
...nextValues.relation,
field: {
...previousValues.relation?.field,
...nextValues.relation?.field,
},
},
});
const initForm = (lazyInitialFormValues: PartialFormValues) => {
if (isInitialized) return;
const mergedFormValues = mergePartialValues(
initialFormValues,
lazyInitialFormValues,
);
setInitialFormValues(mergedFormValues);
setFormValues(mergedFormValues);
setValidationResult(schema.safeParse(mergedFormValues));
setIsInitialized(true);
};
const handleFormChange = (values: PartialFormValues) => {
const nextFormValues = mergePartialValues(formValues, values);
setFormValues(nextFormValues);
setValidationResult(schema.safeParse(nextFormValues));
const {
currency: initialCurrencyFormValues,
relation: initialRelationFormValues,
select: initialSelectFormValues,
multiSelect: initialMultiSelectFormValues,
defaultValue: initialDefaultValue,
...initialFieldFormValues
} = initialFormValues;
const {
currency: nextCurrencyFormValues,
relation: nextRelationFormValues,
select: nextSelectFormValues,
multiSelect: nextMultiSelectFormValues,
defaultValue: nextDefaultValue,
...nextFieldFormValues
} = nextFormValues;
setHasFieldFormChanged(
!isDeeplyEqual(initialFieldFormValues, nextFieldFormValues),
);
setHasCurrencyFormChanged(
nextFieldFormValues.type === FieldMetadataType.Currency &&
!isDeeplyEqual(initialCurrencyFormValues, nextCurrencyFormValues),
);
setHasRelationFormChanged(
nextFieldFormValues.type === FieldMetadataType.Relation &&
!isDeeplyEqual(initialRelationFormValues, nextRelationFormValues),
);
setHasSelectFormChanged(
nextFieldFormValues.type === FieldMetadataType.Select &&
!isDeeplyEqual(initialSelectFormValues, nextSelectFormValues),
);
setHasMultiSelectFormChanged(
nextFieldFormValues.type === FieldMetadataType.MultiSelect &&
!isDeeplyEqual(initialMultiSelectFormValues, nextMultiSelectFormValues),
);
setHasDefaultValueFormChanged(
nextFieldFormValues.type === FieldMetadataType.Boolean &&
!isDeeplyEqual(initialDefaultValue, nextDefaultValue),
);
};
return {
formValues,
handleFormChange,
hasFieldFormChanged,
hasFormChanged:
hasFieldFormChanged ||
hasCurrencyFormChanged ||
hasRelationFormChanged ||
hasSelectFormChanged ||
hasMultiSelectFormChanged ||
hasDefaultValueChanged,
hasRelationFormChanged,
hasSelectFormChanged,
hasMultiSelectFormChanged,
hasDefaultValueChanged,
initForm,
isInitialized,
isValid: validationResult.success,
validatedFormValues: validationResult.success
? validationResult.data
: undefined,
};
};

View File

@ -1,3 +1,11 @@
import { settingsDataModelFieldAboutFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldAboutForm';
import { z } from 'zod';
export const settingsFieldFormSchema = settingsDataModelFieldAboutFormSchema;
import { settingsDataModelFieldAboutFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldAboutForm';
import { settingsDataModelFieldSettingsFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard';
import { settingsDataModelFieldTypeFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect';
export const settingsFieldFormSchema = z
.object({})
.merge(settingsDataModelFieldAboutFormSchema)
.merge(settingsDataModelFieldTypeFormSchema)
.and(settingsDataModelFieldSettingsFormSchema);

View File

@ -8,7 +8,7 @@ import { FieldDisplay } from '@/object-record/record-field/components/FieldDispl
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { BooleanFieldInput } from '@/object-record/record-field/meta-types/input/components/BooleanFieldInput';
import { RatingFieldInput } from '@/object-record/record-field/meta-types/input/components/RatingFieldInput';
import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { SettingsDataModelSetFieldValueEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetFieldValueEffect';
import { SettingsDataModelSetRecordEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect';
import { useFieldPreview } from '@/settings/data-model/fields/preview/hooks/useFieldPreview';
@ -24,7 +24,7 @@ export type SettingsDataModelFieldPreviewProps = {
};
objectMetadataItem: ObjectMetadataItem;
relationObjectMetadataItem?: ObjectMetadataItem;
selectOptions?: SettingsObjectFieldSelectFormValues;
selectOptions?: SettingsDataModelFieldSelectFormValues['options'];
shrink?: boolean;
withFieldLabel?: boolean;
};

View File

@ -4,7 +4,7 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { getFieldDefaultPreviewValue } from '@/settings/data-model/utils/getFieldDefaultPreviewValue';
import { getFieldPreviewValueFromRecord } from '@/settings/data-model/utils/getFieldPreviewValueFromRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@ -16,7 +16,7 @@ type UseFieldPreviewParams = {
};
objectMetadataItem: ObjectMetadataItem;
relationObjectMetadataItem?: ObjectMetadataItem;
selectOptions?: SettingsObjectFieldSelectFormValues;
selectOptions?: SettingsDataModelFieldSelectFormValues['options'];
};
export const useFieldPreview = ({

View File

@ -3,7 +3,6 @@ import styled from '@emotion/styled';
import { z } from 'zod';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { validateMetadataLabel } from '@/object-metadata/utils/validateMetadataLabel';
import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextArea } from '@/ui/input/components/TextArea';
@ -93,11 +92,7 @@ export const SettingsDataModelObjectAboutForm = ({
label={label}
placeholder={placeholder}
value={value}
onChange={(value) => {
if (!value || validateMetadataLabel(value)) {
onChange?.(value);
}
}}
onChange={onChange}
disabled={disabled}
fullWidth
/>

View File

@ -1,5 +1,5 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import {
mockedCompanyObjectMetadataItem,
mockedOpportunityObjectMetadataItem,
@ -16,8 +16,19 @@ describe('getFieldDefaultPreviewValue', () => {
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
({ name }) => name === 'stage',
)!;
const selectOptions: SettingsObjectFieldSelectFormValues =
fieldMetadataItem.options ?? [];
const selectOptions: SettingsDataModelFieldSelectFormValues['options'] = [
{
color: 'purple',
label: '🏭 Industry',
value: 'INDUSTRY',
},
{
color: 'pink',
isDefault: true,
label: '💊 Health',
value: 'HEALTH',
},
];
// When
const result = getFieldDefaultPreviewValue({
@ -27,7 +38,7 @@ describe('getFieldDefaultPreviewValue', () => {
});
// Then
expect(result).toEqual(selectOptions[0].value);
expect(result).toEqual(selectOptions[1].value);
});
it('returns the first select option if no default option was found', () => {
@ -36,8 +47,18 @@ describe('getFieldDefaultPreviewValue', () => {
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
({ name }) => name === 'stage',
)!;
const selectOptions: SettingsObjectFieldSelectFormValues =
fieldMetadataItem.options ?? [];
const selectOptions: SettingsDataModelFieldSelectFormValues['options'] = [
{
color: 'purple' as const,
label: '🏭 Industry',
value: 'INDUSTRY',
},
{
color: 'pink' as const,
label: '💊 Health',
value: 'HEALTH',
},
];
// When
const result = getFieldDefaultPreviewValue({

View File

@ -1,5 +1,5 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import {
mockedCompanyObjectMetadataItem,
mockedOpportunityObjectMetadataItem,
@ -20,8 +20,34 @@ describe('getFieldPreviewValueFromRecord', () => {
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
({ name }) => name === 'stage',
)!;
const selectOptions: SettingsObjectFieldSelectFormValues =
fieldMetadataItem.options ?? [];
const selectOptions: SettingsDataModelFieldSelectFormValues['options'] = [
{
color: 'red',
label: 'New',
value: 'NEW',
},
{
color: 'purple',
label: 'Screening',
value: 'SCREENING',
},
{
color: 'sky',
label: 'Meeting',
value: 'MEETING',
isDefault: true,
},
{
color: 'turquoise',
label: 'Proposal',
value: 'PROPOSAL',
},
{
color: 'yellow',
label: 'Customer',
value: 'CUSTOMER',
},
];
// When
const result = getFieldPreviewValueFromRecord({
@ -44,8 +70,24 @@ describe('getFieldPreviewValueFromRecord', () => {
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
({ name }) => name === 'stage',
)!;
const selectOptions: SettingsObjectFieldSelectFormValues =
fieldMetadataItem.options ?? [];
const selectOptions: SettingsDataModelFieldSelectFormValues['options'] = [
{
color: 'purple',
label: '🏭 Industry',
value: 'INDUSTRY',
},
{
color: 'pink',
isDefault: true,
label: '💊 Health',
value: 'HEALTH',
},
{
color: 'turquoise',
label: '🌿 Green tech',
value: 'GREEN_TECH',
},
];
// When
const result = getFieldPreviewValueFromRecord({

View File

@ -2,7 +2,7 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
@ -19,7 +19,7 @@ export const getFieldDefaultPreviewValue = ({
};
objectMetadataItem: ObjectMetadataItem;
relationObjectMetadataItem?: ObjectMetadataItem;
selectOptions?: SettingsObjectFieldSelectFormValues;
selectOptions?: SettingsDataModelFieldSelectFormValues['options'];
}) => {
if (
fieldMetadataItem.type === FieldMetadataType.Select &&

View File

@ -1,6 +1,6 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const getFieldPreviewValueFromRecord = ({
@ -10,7 +10,7 @@ export const getFieldPreviewValueFromRecord = ({
}: {
record: ObjectRecord;
fieldMetadataItem: Pick<FieldMetadataItem, 'name' | 'type'>;
selectOptions?: SettingsObjectFieldSelectFormValues;
selectOptions?: SettingsDataModelFieldSelectFormValues['options'];
}) => {
const recordFieldValue = record[fieldMetadataItem.name];

View File

@ -4,25 +4,27 @@ import { useNavigate, useParams } from 'react-router-dom';
import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import { isNonEmptyString } from '@sniptt/guards';
import omit from 'lodash.omit';
import pick from 'lodash.pick';
import { IconArchive, IconSettings } from 'twenty-ui';
import { v4 } from 'uuid';
import { z } from 'zod';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useUpdateOneFieldMetadataItem } from '@/object-metadata/hooks/useUpdateOneFieldMetadataItem';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { formatFieldMetadataItemInput } from '@/object-metadata/utils/formatFieldMetadataItemInput';
import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectFieldCurrencyFormValues } from '@/settings/data-model/components/SettingsObjectFieldCurrencyForm';
import { SettingsDataModelFieldAboutForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldAboutForm';
import { SettingsDataModelFieldSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard';
import { SettingsDataModelFieldTypeSelect } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect';
import { useFieldMetadataForm } from '@/settings/data-model/fields/forms/hooks/useFieldMetadataForm';
import { settingsFieldFormSchema } from '@/settings/data-model/fields/forms/validation-schemas/settingsFieldFormSchema';
import { isFieldTypeSupportedInSettings } from '@/settings/data-model/utils/isFieldTypeSupportedInSettings';
import { AppPath } from '@/types/AppPath';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@ -30,10 +32,7 @@ import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type SettingsDataModelFieldEditFormValues = z.infer<
typeof settingsFieldFormSchema
@ -66,117 +65,38 @@ export const SettingsObjectFieldEdit = () => {
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
const { disableMetadataField, editMetadataField } = useFieldMetadataItem();
const { disableMetadataField } = useFieldMetadataItem();
const activeMetadataField = activeObjectMetadataItem?.fields.find(
(metadataField) =>
metadataField.isActive && getFieldSlug(metadataField) === fieldSlug,
);
const getRelationMetadata = useGetRelationMetadata();
const {
relationFieldMetadataItem,
relationObjectMetadataItem,
relationType,
} =
const { relationFieldMetadataItem } =
useMemo(
() =>
activeMetadataField
? getRelationMetadata({
fieldMetadataItem: activeMetadataField,
})
? getRelationMetadata({ fieldMetadataItem: activeMetadataField })
: null,
[activeMetadataField, getRelationMetadata],
) ?? {};
const { updateOneFieldMetadataItem } = useUpdateOneFieldMetadataItem();
const formConfig = useForm<SettingsDataModelFieldEditFormValues>({
mode: 'onTouched',
resolver: zodResolver(settingsFieldFormSchema),
});
const {
formValues,
handleFormChange,
hasFieldFormChanged,
hasDefaultValueChanged,
hasFormChanged,
hasRelationFormChanged,
hasSelectFormChanged,
hasMultiSelectFormChanged,
initForm,
isInitialized,
isValid,
validatedFormValues,
} = useFieldMetadataForm();
useEffect(() => {
if (!activeObjectMetadataItem || !activeMetadataField) {
navigate(AppPath.NotFound);
return;
}
}, [activeMetadataField, activeObjectMetadataItem, navigate]);
const { defaultValue } = activeMetadataField;
if (!activeObjectMetadataItem || !activeMetadataField) return null;
const currencyDefaultValue =
activeMetadataField.type === FieldMetadataType.Currency
? (defaultValue as SettingsObjectFieldCurrencyFormValues | undefined)
: undefined;
const selectOptions = activeMetadataField.options?.map((option) => ({
...option,
isDefault: defaultValue === `'${option.value}'`,
}));
selectOptions?.sort(
(optionA, optionB) => optionA.position - optionB.position,
);
const multiSelectOptions = activeMetadataField.options?.map((option) => ({
...option,
isDefault: defaultValue?.includes(`'${option.value}'`) || false,
}));
multiSelectOptions?.sort(
(optionA, optionB) => optionA.position - optionB.position,
);
const fieldType = activeMetadataField.type;
const isFieldTypeSupported = isFieldTypeSupportedInSettings(fieldType);
if (!isFieldTypeSupported) return;
initForm({
type: fieldType,
...(currencyDefaultValue ? { currency: currencyDefaultValue } : {}),
relation: {
field: {
icon: relationFieldMetadataItem?.icon,
label: relationFieldMetadataItem?.label || '',
},
objectMetadataId: relationObjectMetadataItem?.id || '',
type: relationType || RelationMetadataType.OneToMany,
},
defaultValue: activeMetadataField.defaultValue,
...(selectOptions?.length ? { select: selectOptions } : {}),
...(multiSelectOptions?.length
? { multiSelect: multiSelectOptions }
: {}),
});
}, [
activeMetadataField,
activeObjectMetadataItem,
initForm,
navigate,
relationFieldMetadataItem?.icon,
relationFieldMetadataItem?.label,
relationObjectMetadataItem?.id,
relationType,
]);
if (!isInitialized || !activeObjectMetadataItem || !activeMetadataField)
return null;
const canSave =
formConfig.formState.isValid &&
isValid &&
(formConfig.formState.isDirty || hasFormChanged);
const canSave = formConfig.formState.isValid && formConfig.formState.isDirty;
const isLabelIdentifier = isLabelIdentifierField({
fieldMetadataItem: activeMetadataField,
@ -184,43 +104,37 @@ export const SettingsObjectFieldEdit = () => {
});
const handleSave = async () => {
if (!validatedFormValues) return;
const formValues = formConfig.getValues();
const { dirtyFields } = formConfig.formState;
try {
if (
validatedFormValues.type === FieldMetadataType.Relation &&
formValues.type === FieldMetadataType.Relation &&
isNonEmptyString(relationFieldMetadataItem?.id) &&
hasRelationFormChanged
'relation' in dirtyFields
) {
await editMetadataField({
icon: validatedFormValues.relation.field.icon,
id: relationFieldMetadataItem?.id,
label: validatedFormValues.relation.field.label,
type: validatedFormValues.type,
await updateOneFieldMetadataItem({
fieldMetadataIdToUpdate: relationFieldMetadataItem.id,
updatePayload: formValues.relation.field,
});
}
if (
Object.keys(dirtyFields).length > 0 ||
hasFieldFormChanged ||
hasSelectFormChanged ||
hasMultiSelectFormChanged ||
hasDefaultValueChanged
) {
await editMetadataField({
...formValues,
id: activeMetadataField.id,
defaultValue: validatedFormValues.defaultValue,
type: validatedFormValues.type,
options:
validatedFormValues.type === FieldMetadataType.Select
? validatedFormValues.select
: validatedFormValues.type === FieldMetadataType.MultiSelect
? validatedFormValues.multiSelect
: undefined,
const otherDirtyFields = omit(dirtyFields, 'relation');
if (Object.keys(otherDirtyFields).length > 0) {
const formattedInput = pick(
formatFieldMetadataItemInput(formValues),
Object.keys(otherDirtyFields),
);
const options = formattedInput.options?.map((option) => ({
...option,
id: option.id ?? v4(),
}));
await updateOneFieldMetadataItem({
fieldMetadataIdToUpdate: activeMetadataField.id,
updatePayload: { ...formattedInput, options },
});
}
@ -281,28 +195,13 @@ export const SettingsObjectFieldEdit = () => {
/>
<StyledSettingsObjectFieldTypeSelect
disabled
onChange={handleFormChange}
value={formValues.type}
fieldMetadataItem={activeMetadataField}
/>
<SettingsDataModelFieldSettingsFormCard
disableCurrencyForm
fieldMetadataItem={{
icon: formConfig.watch('icon'),
id: activeMetadataField.id,
label: formConfig.watch('label'),
name: activeMetadataField.name,
type: formValues.type,
}}
fieldMetadataItem={activeMetadataField}
objectMetadataItem={activeObjectMetadataItem}
onChange={handleFormChange}
relationFieldMetadataItem={relationFieldMetadataItem}
values={{
currency: formValues.currency,
relation: formValues.relation,
select: formValues.select,
multiSelect: formValues.multiSelect,
defaultValue: formValues.defaultValue,
}}
/>
</Section>
{!isLabelIdentifier && (

View File

@ -22,7 +22,6 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
import { SettingsDataModelFieldAboutForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldAboutForm';
import { SettingsDataModelFieldSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard';
import { SettingsDataModelFieldTypeSelect } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect';
import { useFieldMetadataForm } from '@/settings/data-model/fields/forms/hooks/useFieldMetadataForm';
import { settingsFieldFormSchema } from '@/settings/data-model/fields/forms/validation-schemas/settingsFieldFormSchema';
import { SettingsSupportedFieldType } from '@/settings/data-model/types/SettingsSupportedFieldType';
import { AppPath } from '@/types/AppPath';
@ -51,25 +50,14 @@ export const SettingsObjectNewFieldStep2 = () => {
const { objectSlug = '' } = useParams();
const { enqueueSnackBar } = useSnackBar();
const {
findActiveObjectMetadataItemBySlug,
findObjectMetadataItemById,
findObjectMetadataItemByNamePlural,
} = useFilteredObjectMetadataItems();
const { findActiveObjectMetadataItemBySlug, findObjectMetadataItemById } =
useFilteredObjectMetadataItems();
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
const { createMetadataField } = useFieldMetadataItem();
const cache = useApolloClient().cache;
const {
formValues,
handleFormChange,
initForm,
isValid,
validatedFormValues,
} = useFieldMetadataForm();
const formConfig = useForm<SettingsDataModelNewFieldFormValues>({
mode: 'onTouched',
resolver: zodResolver(settingsFieldFormSchema),
@ -78,23 +66,8 @@ export const SettingsObjectNewFieldStep2 = () => {
useEffect(() => {
if (!activeObjectMetadataItem) {
navigate(AppPath.NotFound);
return;
}
initForm({
relation: {
field: { icon: activeObjectMetadataItem.icon },
objectMetadataId:
findObjectMetadataItemByNamePlural('people')?.id || '',
},
});
}, [
activeObjectMetadataItem,
findObjectMetadataItemByNamePlural,
initForm,
navigate,
]);
}, [activeObjectMetadataItem, navigate]);
const [objectViews, setObjectViews] = useState<View[]>([]);
const [relationObjectViews, setRelationObjectViews] = useState<View[]>([]);
@ -116,12 +89,16 @@ export const SettingsObjectNewFieldStep2 = () => {
},
});
const relationObjectMetadataId = formConfig.watch(
'relation.objectMetadataId',
);
useFindManyRecords<View>({
objectNameSingular: CoreObjectNameSingular.View,
skip: !formValues.relation?.objectMetadataId,
skip: !relationObjectMetadataId,
filter: {
type: { eq: ViewType.Table },
objectMetadataId: { eq: formValues.relation?.objectMetadataId },
objectMetadataId: { eq: relationObjectMetadataId },
},
onCompleted: async (views) => {
if (isUndefinedOrNull(views)) return;
@ -135,37 +112,40 @@ export const SettingsObjectNewFieldStep2 = () => {
if (!activeObjectMetadataItem) return null;
const canSave = formConfig.formState.isValid && isValid;
const canSave = formConfig.formState.isValid;
const handleSave = async () => {
if (!validatedFormValues) return;
const formValues = formConfig.getValues();
try {
if (validatedFormValues.type === FieldMetadataType.Relation) {
if (
formValues.type === FieldMetadataType.Relation &&
'relation' in formValues
) {
const { relation: relationFormValues, ...fieldFormValues } = formValues;
const createdRelation = await createOneRelationMetadata({
relationType: validatedFormValues.relation.type,
field: pick(formValues, ['icon', 'label', 'description']),
relationType: relationFormValues.type,
field: pick(fieldFormValues, ['icon', 'label', 'description']),
objectMetadataId: activeObjectMetadataItem.id,
connect: {
field: {
icon: validatedFormValues.relation.field.icon,
label: validatedFormValues.relation.field.label,
icon: relationFormValues.field.icon,
label: relationFormValues.field.label,
},
objectMetadataId: validatedFormValues.relation.objectMetadataId,
objectMetadataId: relationFormValues.objectMetadataId,
},
});
const relationObjectMetadataItem = findObjectMetadataItemById(
validatedFormValues.relation.objectMetadataId,
relationFormValues.objectMetadataId,
);
objectViews.map(async (view) => {
const viewFieldToCreate = {
viewId: view.id,
fieldMetadataId:
validatedFormValues.relation.type === 'MANY_TO_ONE'
relationFormValues.type === 'MANY_TO_ONE'
? createdRelation.data?.createOneRelation.toFieldMetadataId
: createdRelation.data?.createOneRelation.fromFieldMetadataId,
position: activeObjectMetadataItem.fields.length,
@ -198,7 +178,7 @@ export const SettingsObjectNewFieldStep2 = () => {
const viewFieldToCreate = {
viewId: view.id,
fieldMetadataId:
validatedFormValues.relation.type === 'MANY_TO_ONE'
relationFormValues.type === 'MANY_TO_ONE'
? createdRelation.data?.createOneRelation.fromFieldMetadataId
: createdRelation.data?.createOneRelation.toFieldMetadataId,
position: relationObjectMetadataItem?.fields.length,
@ -229,22 +209,17 @@ export const SettingsObjectNewFieldStep2 = () => {
});
} else {
const createdMetadataField = await createMetadataField({
defaultValue:
validatedFormValues.type === FieldMetadataType.Currency
? {
amountMicros: null,
currencyCode: validatedFormValues.currency.currencyCode,
}
: validatedFormValues.defaultValue,
...formValues,
objectMetadataId: activeObjectMetadataItem.id,
type: validatedFormValues.type,
options:
validatedFormValues.type === FieldMetadataType.Select
? validatedFormValues.select
: validatedFormValues.type === FieldMetadataType.MultiSelect
? validatedFormValues.multiSelect
defaultValue:
formValues.type === FieldMetadataType.Currency
? {
...formValues.defaultValue,
amountMicros: null,
}
: 'defaultValue' in formValues
? formValues.defaultValue
: undefined,
objectMetadataId: activeObjectMetadataItem.id,
});
objectViews.map(async (view) => {
@ -335,24 +310,14 @@ export const SettingsObjectNewFieldStep2 = () => {
/>
<StyledSettingsObjectFieldTypeSelect
excludedFieldTypes={excludedFieldTypes}
onChange={handleFormChange}
value={formValues.type}
/>
<SettingsDataModelFieldSettingsFormCard
fieldMetadataItem={{
icon: formConfig.watch('icon'),
label: formConfig.watch('label') || 'Employees',
type: formValues.type,
type: formConfig.watch('type'),
}}
objectMetadataItem={activeObjectMetadataItem}
onChange={handleFormChange}
values={{
currency: formValues.currency,
relation: formValues.relation,
select: formValues.select,
multiSelect: formValues.multiSelect,
defaultValue: formValues.defaultValue,
}}
/>
</Section>
</SettingsPageContainer>