refactor: use react-hook-form to validate Settings/DataModel/Field (#4916)

Closes #4295
This commit is contained in:
Thaïs
2024-05-07 11:44:46 +02:00
committed by GitHub
parent 9c25c1beae
commit d0759ad7cc
18 changed files with 13234 additions and 13369 deletions

View File

@ -124,7 +124,8 @@
"commands": [ "commands": [
"npx concurrently --kill-others --success=first -n SB,TEST 'nx storybook:static {projectName} --port={args.port}' 'npx wait-on tcp:{args.port} && nx storybook:test {projectName} --port={args.port} --configuration={args.scope}'" "npx concurrently --kill-others --success=first -n SB,TEST 'nx storybook:static {projectName} --port={args.port}' 'npx wait-on tcp:{args.port} && nx storybook:test {projectName} --port={args.port} --configuration={args.scope}'"
], ],
"port": 6006 "port": 6006,
"env": { "NODE_OPTIONS": "--max-old-space-size=5000" }
}, },
"configurations": { "configurations": {
"docs": { "scope": "ui-docs" }, "docs": { "scope": "ui-docs" },

View File

@ -4,8 +4,7 @@ import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import { useCreateOneRelationMetadataItem } from '@/object-metadata/hooks/useCreateOneRelationMetadataItem'; import { useCreateOneRelationMetadataItem } from '@/object-metadata/hooks/useCreateOneRelationMetadataItem';
import { FieldMetadataType } from '~/generated/graphql'; import { RelationMetadataType } from '~/generated/graphql';
import { RelationMetadataType } from '~/generated-metadata/graphql';
import { import {
query, query,
@ -46,7 +45,6 @@ describe('useCreateOneRelationMetadataItem', () => {
relationType: RelationMetadataType.OneToOne, relationType: RelationMetadataType.OneToOne,
field: { field: {
label: 'label', label: 'label',
type: FieldMetadataType.Relation,
}, },
objectMetadataId: 'objectMetadataId', objectMetadataId: 'objectMetadataId',
connect: { connect: {

View File

@ -9,7 +9,7 @@ import { formatFieldMetadataItemInput } from './formatFieldMetadataItemInput';
export type FormatRelationMetadataInputParams = { export type FormatRelationMetadataInputParams = {
relationType: RelationType; relationType: RelationType;
field: Pick<Field, 'label' | 'icon' | 'description' | 'type'>; field: Pick<Field, 'label' | 'icon' | 'description'>;
objectMetadataId: string; objectMetadataId: string;
connect: { connect: {
field: Pick<Field, 'label' | 'icon'>; field: Pick<Field, 'label' | 'icon'>;

View File

@ -1,6 +1,3 @@
import { SafeParseSuccess } from 'zod';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mockedCompanyObjectMetadataItem } from '~/testing/mock-data/metadata'; import { mockedCompanyObjectMetadataItem } from '~/testing/mock-data/metadata';
import { objectMetadataItemSchema } from '../objectMetadataItemSchema'; import { objectMetadataItemSchema } from '../objectMetadataItemSchema';
@ -11,13 +8,10 @@ describe('objectMetadataItemSchema', () => {
const validObjectMetadataItem = mockedCompanyObjectMetadataItem; const validObjectMetadataItem = mockedCompanyObjectMetadataItem;
// When // When
const result = objectMetadataItemSchema.safeParse(validObjectMetadataItem); const result = objectMetadataItemSchema.parse(validObjectMetadataItem);
// Then // Then
expect(result.success).toBe(true); expect(result).toEqual(validObjectMetadataItem);
expect((result as SafeParseSuccess<ObjectMetadataItem>).data).toEqual(
validObjectMetadataItem,
);
}); });
it('fails for an invalid object metadata item', () => { it('fails for an invalid object metadata item', () => {

View File

@ -1,6 +1,104 @@
import { z } from 'zod'; import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { metadataLabelSchema } from '@/object-metadata/validation-schemas/metadataLabelSchema';
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
import {
FieldMetadataType,
RelationDefinitionType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
import { camelCaseStringSchema } from '~/utils/validation-schemas/camelCaseStringSchema';
// TODO: implement fieldMetadataItemSchema export const fieldMetadataItemSchema = z.object({
export const fieldMetadataItemSchema: z.ZodType<FieldMetadataItem> = z.any(); __typename: z.literal('field').optional(),
createdAt: z.string().datetime(),
defaultValue: z.any().optional(),
description: z.string().trim().nullable().optional(),
fromRelationMetadata: z
.object({
__typename: z.literal('relation').optional(),
id: z.string().uuid(),
relationType: z.nativeEnum(RelationMetadataType),
toFieldMetadataId: z.string().uuid(),
toObjectMetadata: z.object({
__typename: z.literal('object').optional(),
dataSourceId: z.string().uuid(),
id: z.string().uuid(),
isRemote: z.boolean(),
isSystem: z.boolean(),
namePlural: z.string().trim().min(1),
nameSingular: z.string().trim().min(1),
}),
})
.nullable()
.optional(),
icon: z.string().startsWith('Icon').trim().nullable(),
id: z.string().uuid(),
isActive: z.boolean(),
isCustom: z.boolean(),
isNullable: z.boolean(),
isSystem: z.boolean(),
label: metadataLabelSchema,
name: camelCaseStringSchema,
options: z
.array(
z.object({
color: themeColorSchema,
id: z.string().uuid(),
label: z.string().trim().min(1),
position: z.number(),
value: z.string().trim().min(1),
}),
)
.optional(),
relationDefinition: z
.object({
__typename: z.literal('RelationDefinition').optional(),
direction: z.nativeEnum(RelationDefinitionType),
sourceFieldMetadata: z.object({
__typename: z.literal('field').optional(),
id: z.string().uuid(),
name: z.string().trim().min(1),
}),
sourceObjectMetadata: z.object({
__typename: z.literal('object').optional(),
id: z.string().uuid(),
namePlural: z.string().trim().min(1),
nameSingular: z.string().trim().min(1),
}),
targetFieldMetadata: z.object({
__typename: z.literal('field').optional(),
id: z.string().uuid(),
name: z.string().trim().min(1),
}),
targetObjectMetadata: z.object({
__typename: z.literal('object').optional(),
id: z.string().uuid(),
namePlural: z.string().trim().min(1),
nameSingular: z.string().trim().min(1),
}),
})
.nullable()
.optional(),
toRelationMetadata: z
.object({
__typename: z.literal('relation').optional(),
id: z.string().uuid(),
relationType: z.nativeEnum(RelationMetadataType),
fromFieldMetadataId: z.string().uuid(),
fromObjectMetadata: z.object({
__typename: z.literal('object').optional(),
id: z.string().uuid(),
dataSourceId: z.string().uuid(),
isRemote: z.boolean(),
isSystem: z.boolean(),
namePlural: z.string().trim().min(1),
nameSingular: z.string().trim().min(1),
}),
})
.nullable()
.optional(),
type: z.nativeEnum(FieldMetadataType),
updatedAt: z.string().datetime(),
}) satisfies z.ZodType<FieldMetadataItem>;

View File

@ -0,0 +1,7 @@
import { z } from 'zod';
export const metadataLabelSchema = z
.string()
.trim()
.min(1)
.regex(/^[a-zA-Z][a-zA-Z0-9 ()]*$/);

View File

@ -2,6 +2,7 @@ import { z } from 'zod';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema'; import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema';
import { metadataLabelSchema } from '@/object-metadata/validation-schemas/metadataLabelSchema';
import { camelCaseStringSchema } from '~/utils/validation-schemas/camelCaseStringSchema'; import { camelCaseStringSchema } from '~/utils/validation-schemas/camelCaseStringSchema';
export const objectMetadataItemSchema = z.object({ export const objectMetadataItemSchema = z.object({
@ -18,8 +19,8 @@ export const objectMetadataItemSchema = z.object({
isRemote: z.boolean(), isRemote: z.boolean(),
isSystem: z.boolean(), isSystem: z.boolean(),
labelIdentifierFieldMetadataId: z.string().uuid().nullable(), labelIdentifierFieldMetadataId: z.string().uuid().nullable(),
labelPlural: z.string().trim().min(1), labelPlural: metadataLabelSchema,
labelSingular: z.string().trim().min(1), labelSingular: metadataLabelSchema,
namePlural: camelCaseStringSchema, namePlural: camelCaseStringSchema,
nameSingular: camelCaseStringSchema, nameSingular: camelCaseStringSchema,
updatedAt: z.string().datetime(), updatedAt: z.string().datetime(),

View File

@ -1,70 +0,0 @@
import styled from '@emotion/styled';
import { validateMetadataLabel } from '@/object-metadata/utils/validateMetadataLabel';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
import { Section } from '@/ui/layout/section/components/Section';
type SettingsObjectFieldFormSectionProps = {
disabled?: boolean;
name?: string;
description?: string;
iconKey?: string;
onChange?: (
formValues: Partial<{
icon: string;
label: string;
description: string;
}>,
) => void;
};
const StyledInputsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export const SettingsObjectFieldFormSection = ({
disabled,
name = '',
description = '',
iconKey = 'IconUsers',
onChange,
}: SettingsObjectFieldFormSectionProps) => (
<Section>
<H2Title
title="Name and description"
description="The name and description of this field"
/>
<StyledInputsContainer>
<IconPicker
disabled={disabled}
selectedIconKey={iconKey}
onChange={(value) => onChange?.({ icon: value.iconKey })}
variant="primary"
/>
<TextInput
placeholder="Employees"
value={name}
onChange={(value) => {
if (!value || validateMetadataLabel(value)) {
onChange?.({ label: value });
}
}}
disabled={disabled}
fullWidth
/>
</StyledInputsContainer>
<TextArea
placeholder="Write a description"
minRows={4}
value={description}
onChange={(value) => onChange?.({ description: value })}
disabled={disabled}
/>
</Section>
);

View File

@ -1,23 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { SettingsObjectFieldFormSection } from '../SettingsObjectFieldFormSection';
const meta: Meta<typeof SettingsObjectFieldFormSection> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldFormSection',
component: SettingsObjectFieldFormSection,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof SettingsObjectFieldFormSection>;
export const Default: Story = {};
export const WithDefaultValues: Story = {
args: {
iconKey: 'IconLink',
name: 'URL',
description: 'Lorem ipsum',
},
};

View File

@ -0,0 +1,87 @@
import { Controller, useFormContext } from 'react-hook-form';
import styled from '@emotion/styled';
import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
export const settingsDataModelFieldAboutFormSchema =
fieldMetadataItemSchema.pick({
description: true,
icon: true,
label: true,
});
type SettingsDataModelFieldAboutFormValues = z.infer<
typeof settingsDataModelFieldAboutFormSchema
>;
type SettingsDataModelFieldAboutFormProps = {
disabled?: boolean;
fieldMetadataItem?: FieldMetadataItem;
};
const StyledInputsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export const SettingsDataModelFieldAboutForm = ({
disabled,
fieldMetadataItem,
}: SettingsDataModelFieldAboutFormProps) => {
const { control } = useFormContext<SettingsDataModelFieldAboutFormValues>();
return (
<>
<StyledInputsContainer>
<Controller
name="icon"
control={control}
defaultValue={fieldMetadataItem?.icon ?? 'IconUsers'}
render={({ field: { onChange, value } }) => (
<IconPicker
disabled={disabled}
selectedIconKey={value ?? ''}
onChange={({ iconKey }) => onChange(iconKey)}
variant="primary"
/>
)}
/>
<Controller
name="label"
control={control}
defaultValue={fieldMetadataItem?.label}
render={({ field: { onChange, value } }) => (
<TextInput
placeholder="Employees"
value={value}
onChange={onChange}
disabled={disabled}
fullWidth
/>
)}
/>
</StyledInputsContainer>
<Controller
name="description"
control={control}
defaultValue={fieldMetadataItem?.description}
render={({ field: { onChange, value } }) => (
<TextArea
placeholder="Write a description"
minRows={4}
value={value ?? undefined}
onChange={onChange}
disabled={disabled}
/>
)}
/>
</>
);
};

View File

@ -0,0 +1,47 @@
import styled from '@emotion/styled';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { FormProviderDecorator } from '~/testing/decorators/FormProviderDecorator';
import { IconsProviderDecorator } from '~/testing/decorators/IconsProviderDecorator';
import { mockedPersonObjectMetadataItem } from '~/testing/mock-data/metadata';
import { SettingsDataModelFieldAboutForm } from '../SettingsDataModelFieldAboutForm';
const StyledContainer = styled.div`
flex: 1;
`;
const meta: Meta<typeof SettingsDataModelFieldAboutForm> = {
title: 'Modules/Settings/DataModel/SettingsDataModelFieldAboutForm',
component: SettingsDataModelFieldAboutForm,
decorators: [
(Story) => (
<StyledContainer>
<Story />
</StyledContainer>
),
FormProviderDecorator,
IconsProviderDecorator,
ComponentDecorator,
],
};
export default meta;
type Story = StoryObj<typeof SettingsDataModelFieldAboutForm>;
export const Default: Story = {};
export const WithDefaultValues: Story = {
args: {
fieldMetadataItem: mockedPersonObjectMetadataItem.fields.find(
({ name }) => name === 'name',
)!,
},
};
export const Disabled: Story = {
args: {
disabled: true,
},
};

View File

@ -1,5 +1,7 @@
import { act, renderHook } from '@testing-library/react'; import { act, renderHook } from '@testing-library/react';
import { FieldMetadataType } from '~/generated/graphql';
import { useFieldMetadataForm } from '../useFieldMetadataForm'; import { useFieldMetadataForm } from '../useFieldMetadataForm';
describe('useFieldMetadataForm', () => { describe('useFieldMetadataForm', () => {
@ -14,8 +16,6 @@ describe('useFieldMetadataForm', () => {
expect(result.current.isInitialized).toBe(true); expect(result.current.isInitialized).toBe(true);
expect(result.current.formValues).toEqual({ expect(result.current.formValues).toEqual({
icon: 'IconUsers',
label: '',
type: 'TEXT', type: 'TEXT',
currency: { currencyCode: 'USD' }, currency: { currencyCode: 'USD' },
relation: { relation: {
@ -45,7 +45,7 @@ describe('useFieldMetadataForm', () => {
expect(result.current.hasSelectFormChanged).toBe(false); expect(result.current.hasSelectFormChanged).toBe(false);
act(() => { act(() => {
result.current.handleFormChange({ label: 'New Label' }); result.current.handleFormChange({ type: FieldMetadataType.Number });
}); });
expect(result.current.hasFieldFormChanged).toBe(true); expect(result.current.hasFieldFormChanged).toBe(true);

View File

@ -15,16 +15,11 @@ import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { SettingsDataModelFieldSettingsFormValues } from '../components/SettingsDataModelFieldSettingsFormCard'; import { SettingsDataModelFieldSettingsFormValues } from '../components/SettingsDataModelFieldSettingsFormCard';
type FormValues = { type FormValues = {
description?: string;
icon: string;
label: string;
defaultValue: any; defaultValue: any;
type: SettingsSupportedFieldType; type: SettingsSupportedFieldType;
} & SettingsDataModelFieldSettingsFormValues; } & SettingsDataModelFieldSettingsFormValues;
export const fieldMetadataFormDefaultValues: FormValues = { export const fieldMetadataFormDefaultValues: FormValues = {
icon: 'IconUsers',
label: '',
type: FieldMetadataType.Text, type: FieldMetadataType.Text,
currency: { currencyCode: CurrencyCode.USD }, currency: { currencyCode: CurrencyCode.USD },
relation: { relation: {
@ -43,9 +38,6 @@ const relationTargetFieldSchema = z.object({
defaultValue: z.any(), defaultValue: z.any(),
}); });
const fieldSchema = z.object({ const fieldSchema = z.object({
description: z.string().optional(),
icon: z.string().startsWith('Icon'),
label: z.string().min(1),
defaultValue: z.any(), defaultValue: z.any(),
type: z.enum( type: z.enum(
Object.values(FieldMetadataType) as [ Object.values(FieldMetadataType) as [

View File

@ -0,0 +1,3 @@
import { settingsDataModelFieldAboutFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldAboutForm';
export const settingsFieldFormSchema = settingsDataModelFieldAboutFormSchema;

View File

@ -1,8 +1,11 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { IconArchive, IconSettings } from 'twenty-ui'; import { IconArchive, IconSettings } from 'twenty-ui';
import { z } from 'zod';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem'; import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
@ -14,10 +17,11 @@ import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer'; import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectFieldCurrencyFormValues } from '@/settings/data-model/components/SettingsObjectFieldCurrencyForm'; import { SettingsObjectFieldCurrencyFormValues } from '@/settings/data-model/components/SettingsObjectFieldCurrencyForm';
import { SettingsObjectFieldFormSection } from '@/settings/data-model/components/SettingsObjectFieldFormSection'; import { SettingsDataModelFieldAboutForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldAboutForm';
import { SettingsDataModelFieldSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard'; import { SettingsDataModelFieldSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard';
import { SettingsDataModelFieldTypeSelect } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect'; import { SettingsDataModelFieldTypeSelect } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect';
import { useFieldMetadataForm } from '@/settings/data-model/fields/forms/hooks/useFieldMetadataForm'; 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 { isFieldTypeSupportedInSettings } from '@/settings/data-model/utils/isFieldTypeSupportedInSettings';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { H2Title } from '@/ui/display/typography/components/H2Title'; import { H2Title } from '@/ui/display/typography/components/H2Title';
@ -31,6 +35,10 @@ import {
RelationMetadataType, RelationMetadataType,
} from '~/generated-metadata/graphql'; } from '~/generated-metadata/graphql';
type SettingsDataModelFieldEditFormValues = z.infer<
typeof settingsFieldFormSchema
>;
const StyledSettingsObjectFieldTypeSelect = styled( const StyledSettingsObjectFieldTypeSelect = styled(
SettingsDataModelFieldTypeSelect, SettingsDataModelFieldTypeSelect,
)` )`
@ -80,6 +88,11 @@ export const SettingsObjectFieldEdit = () => {
[activeMetadataField, getRelationMetadata], [activeMetadataField, getRelationMetadata],
) ?? {}; ) ?? {};
const formConfig = useForm<SettingsDataModelFieldEditFormValues>({
mode: 'onTouched',
resolver: zodResolver(settingsFieldFormSchema),
});
const { const {
formValues, formValues,
handleFormChange, handleFormChange,
@ -130,9 +143,6 @@ export const SettingsObjectFieldEdit = () => {
if (!isFieldTypeSupported) return; if (!isFieldTypeSupported) return;
initForm({ initForm({
icon: activeMetadataField.icon ?? undefined,
label: activeMetadataField.label,
description: activeMetadataField.description ?? undefined,
type: fieldType, type: fieldType,
...(currencyDefaultValue ? { currency: currencyDefaultValue } : {}), ...(currencyDefaultValue ? { currency: currencyDefaultValue } : {}),
relation: { relation: {
@ -163,7 +173,10 @@ export const SettingsObjectFieldEdit = () => {
if (!isInitialized || !activeObjectMetadataItem || !activeMetadataField) if (!isInitialized || !activeObjectMetadataItem || !activeMetadataField)
return null; return null;
const canSave = isValid && hasFormChanged; const canSave =
formConfig.formState.isValid &&
isValid &&
(formConfig.formState.isDirty || hasFormChanged);
const isLabelIdentifier = isLabelIdentifierField({ const isLabelIdentifier = isLabelIdentifierField({
fieldMetadataItem: activeMetadataField, fieldMetadataItem: activeMetadataField,
@ -173,6 +186,9 @@ export const SettingsObjectFieldEdit = () => {
const handleSave = async () => { const handleSave = async () => {
if (!validatedFormValues) return; if (!validatedFormValues) return;
const formValues = formConfig.getValues();
const { dirtyFields } = formConfig.formState;
try { try {
if ( if (
validatedFormValues.type === FieldMetadataType.Relation && validatedFormValues.type === FieldMetadataType.Relation &&
@ -186,17 +202,17 @@ export const SettingsObjectFieldEdit = () => {
type: validatedFormValues.type, type: validatedFormValues.type,
}); });
} }
if ( if (
Object.keys(dirtyFields).length > 0 ||
hasFieldFormChanged || hasFieldFormChanged ||
hasSelectFormChanged || hasSelectFormChanged ||
hasMultiSelectFormChanged || hasMultiSelectFormChanged ||
hasDefaultValueChanged hasDefaultValueChanged
) { ) {
await editMetadataField({ await editMetadataField({
description: validatedFormValues.description, ...formValues,
icon: validatedFormValues.icon,
id: activeMetadataField.id, id: activeMetadataField.id,
label: validatedFormValues.label,
defaultValue: validatedFormValues.defaultValue, defaultValue: validatedFormValues.defaultValue,
type: validatedFormValues.type, type: validatedFormValues.type,
options: options:
@ -225,77 +241,83 @@ export const SettingsObjectFieldEdit = () => {
canPersistFieldMetadataItemUpdate(activeMetadataField); canPersistFieldMetadataItemUpdate(activeMetadataField);
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> // eslint-disable-next-line react/jsx-props-no-spreading
<SettingsPageContainer> <FormProvider {...formConfig}>
<SettingsHeaderContainer> <SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<Breadcrumb <SettingsPageContainer>
links={[ <SettingsHeaderContainer>
{ children: 'Objects', href: '/settings/objects' }, <Breadcrumb
{ links={[
children: activeObjectMetadataItem.labelPlural, { children: 'Objects', href: '/settings/objects' },
href: `/settings/objects/${objectSlug}`, {
}, children: activeObjectMetadataItem.labelPlural,
{ children: activeMetadataField.label }, href: `/settings/objects/${objectSlug}`,
]} },
/> { children: activeMetadataField.label },
{shouldDisplaySaveAndCancel && ( ]}
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
onSave={handleSave}
/> />
)} {shouldDisplaySaveAndCancel && (
</SettingsHeaderContainer> <SaveAndCancelButtons
<SettingsObjectFieldFormSection isSaveDisabled={!canSave}
disabled={!activeMetadataField.isCustom} onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
name={formValues.label} onSave={handleSave}
description={formValues.description} />
iconKey={formValues.icon} )}
onChange={handleFormChange} </SettingsHeaderContainer>
/>
<Section>
<H2Title
title="Type and values"
description="The field's type and values."
/>
<StyledSettingsObjectFieldTypeSelect
disabled
onChange={handleFormChange}
value={formValues.type}
/>
<SettingsDataModelFieldSettingsFormCard
disableCurrencyForm
fieldMetadataItem={{
icon: formValues.icon,
id: activeMetadataField.id,
label: formValues.label,
name: activeMetadataField.name,
type: formValues.type,
}}
objectMetadataItem={activeObjectMetadataItem}
onChange={handleFormChange}
relationFieldMetadataItem={relationFieldMetadataItem}
values={{
currency: formValues.currency,
relation: formValues.relation,
select: formValues.select,
multiSelect: formValues.multiSelect,
defaultValue: formValues.defaultValue,
}}
/>
</Section>
{!isLabelIdentifier && (
<Section> <Section>
<H2Title title="Danger zone" description="Disable this field" /> <H2Title
<Button title="Name and description"
Icon={IconArchive} description="The name and description of this field"
title="Disable" />
size="small" <SettingsDataModelFieldAboutForm
onClick={handleDisable} disabled={!activeMetadataField.isCustom}
fieldMetadataItem={activeMetadataField}
/> />
</Section> </Section>
)} <Section>
</SettingsPageContainer> <H2Title
</SubMenuTopBarContainer> title="Type and values"
description="The field's type and values."
/>
<StyledSettingsObjectFieldTypeSelect
disabled
onChange={handleFormChange}
value={formValues.type}
/>
<SettingsDataModelFieldSettingsFormCard
disableCurrencyForm
fieldMetadataItem={{
icon: formConfig.watch('icon'),
id: activeMetadataField.id,
label: formConfig.watch('label'),
name: activeMetadataField.name,
type: formValues.type,
}}
objectMetadataItem={activeObjectMetadataItem}
onChange={handleFormChange}
relationFieldMetadataItem={relationFieldMetadataItem}
values={{
currency: formValues.currency,
relation: formValues.relation,
select: formValues.select,
multiSelect: formValues.multiSelect,
defaultValue: formValues.defaultValue,
}}
/>
</Section>
{!isLabelIdentifier && (
<Section>
<H2Title title="Danger zone" description="Disable this field" />
<Button
Icon={IconArchive}
title="Disable"
size="small"
onClick={handleDisable}
/>
</Section>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
</FormProvider>
); );
}; };

View File

@ -1,8 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Reference, useApolloClient } from '@apollo/client'; import { Reference, useApolloClient } from '@apollo/client';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import pick from 'lodash.pick';
import { IconSettings } from 'twenty-ui'; import { IconSettings } from 'twenty-ui';
import { z } from 'zod';
import { useCreateOneRelationMetadataItem } from '@/object-metadata/hooks/useCreateOneRelationMetadataItem'; import { useCreateOneRelationMetadataItem } from '@/object-metadata/hooks/useCreateOneRelationMetadataItem';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem'; import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
@ -15,10 +19,11 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer'; import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectFieldFormSection } from '@/settings/data-model/components/SettingsObjectFieldFormSection'; import { SettingsDataModelFieldAboutForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldAboutForm';
import { SettingsDataModelFieldSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard'; import { SettingsDataModelFieldSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard';
import { SettingsDataModelFieldTypeSelect } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect'; import { SettingsDataModelFieldTypeSelect } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect';
import { useFieldMetadataForm } from '@/settings/data-model/fields/forms/hooks/useFieldMetadataForm'; 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 { SettingsSupportedFieldType } from '@/settings/data-model/types/SettingsSupportedFieldType';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { H2Title } from '@/ui/display/typography/components/H2Title'; import { H2Title } from '@/ui/display/typography/components/H2Title';
@ -31,6 +36,10 @@ import { ViewType } from '@/views/types/ViewType';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
type SettingsDataModelNewFieldFormValues = z.infer<
typeof settingsFieldFormSchema
>;
const StyledSettingsObjectFieldTypeSelect = styled( const StyledSettingsObjectFieldTypeSelect = styled(
SettingsDataModelFieldTypeSelect, SettingsDataModelFieldTypeSelect,
)` )`
@ -57,10 +66,15 @@ export const SettingsObjectNewFieldStep2 = () => {
formValues, formValues,
handleFormChange, handleFormChange,
initForm, initForm,
isValid: canSave, isValid,
validatedFormValues, validatedFormValues,
} = useFieldMetadataForm(); } = useFieldMetadataForm();
const formConfig = useForm<SettingsDataModelNewFieldFormValues>({
mode: 'onTouched',
resolver: zodResolver(settingsFieldFormSchema),
});
useEffect(() => { useEffect(() => {
if (!activeObjectMetadataItem) { if (!activeObjectMetadataItem) {
navigate(AppPath.NotFound); navigate(AppPath.NotFound);
@ -121,19 +135,18 @@ export const SettingsObjectNewFieldStep2 = () => {
if (!activeObjectMetadataItem) return null; if (!activeObjectMetadataItem) return null;
const canSave = formConfig.formState.isValid && isValid;
const handleSave = async () => { const handleSave = async () => {
if (!validatedFormValues) return; if (!validatedFormValues) return;
const formValues = formConfig.getValues();
try { try {
if (validatedFormValues.type === FieldMetadataType.Relation) { if (validatedFormValues.type === FieldMetadataType.Relation) {
const createdRelation = await createOneRelationMetadata({ const createdRelation = await createOneRelationMetadata({
relationType: validatedFormValues.relation.type, relationType: validatedFormValues.relation.type,
field: { field: pick(formValues, ['icon', 'label', 'description']),
description: validatedFormValues.description,
icon: validatedFormValues.icon,
label: validatedFormValues.label,
type: validatedFormValues.type,
},
objectMetadataId: activeObjectMetadataItem.id, objectMetadataId: activeObjectMetadataItem.id,
connect: { connect: {
field: { field: {
@ -223,9 +236,7 @@ export const SettingsObjectNewFieldStep2 = () => {
currencyCode: validatedFormValues.currency.currencyCode, currencyCode: validatedFormValues.currency.currencyCode,
} }
: validatedFormValues.defaultValue, : validatedFormValues.defaultValue,
description: validatedFormValues.description, ...formValues,
icon: validatedFormValues.icon,
label: validatedFormValues.label ?? '',
objectMetadataId: activeObjectMetadataItem.id, objectMetadataId: activeObjectMetadataItem.id,
type: validatedFormValues.type, type: validatedFormValues.type,
options: options:
@ -287,61 +298,65 @@ export const SettingsObjectNewFieldStep2 = () => {
]; ];
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> // eslint-disable-next-line react/jsx-props-no-spreading
<SettingsPageContainer> <FormProvider {...formConfig}>
<SettingsHeaderContainer> <SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<Breadcrumb <SettingsPageContainer>
links={[ <SettingsHeaderContainer>
{ children: 'Objects', href: '/settings/objects' }, <Breadcrumb
{ links={[
children: activeObjectMetadataItem.labelPlural, { children: 'Objects', href: '/settings/objects' },
href: `/settings/objects/${objectSlug}`, {
}, children: activeObjectMetadataItem.labelPlural,
{ children: 'New Field' }, href: `/settings/objects/${objectSlug}`,
]} },
/> { children: 'New Field' },
{!activeObjectMetadataItem.isRemote && ( ]}
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
onSave={handleSave}
/> />
)} {!activeObjectMetadataItem.isRemote && (
</SettingsHeaderContainer> <SaveAndCancelButtons
<SettingsObjectFieldFormSection isSaveDisabled={!canSave}
iconKey={formValues.icon} onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
name={formValues.label} onSave={handleSave}
description={formValues.description} />
onChange={handleFormChange} )}
/> </SettingsHeaderContainer>
<Section> <Section>
<H2Title <H2Title
title="Type and values" title="Name and description"
description="The field's type and values." description="The name and description of this field"
/> />
<StyledSettingsObjectFieldTypeSelect <SettingsDataModelFieldAboutForm />
excludedFieldTypes={excludedFieldTypes} </Section>
onChange={handleFormChange} <Section>
value={formValues.type} <H2Title
/> title="Type and values"
<SettingsDataModelFieldSettingsFormCard description="The field's type and values."
fieldMetadataItem={{ />
icon: formValues.icon, <StyledSettingsObjectFieldTypeSelect
label: formValues.label || 'Employees', excludedFieldTypes={excludedFieldTypes}
type: formValues.type, onChange={handleFormChange}
}} value={formValues.type}
objectMetadataItem={activeObjectMetadataItem} />
onChange={handleFormChange} <SettingsDataModelFieldSettingsFormCard
values={{ fieldMetadataItem={{
currency: formValues.currency, icon: formConfig.watch('icon'),
relation: formValues.relation, label: formConfig.watch('label') || 'Employees',
select: formValues.select, type: formValues.type,
multiSelect: formValues.multiSelect, }}
defaultValue: formValues.defaultValue, objectMetadataItem={activeObjectMetadataItem}
}} onChange={handleFormChange}
/> values={{
</Section> currency: formValues.currency,
</SettingsPageContainer> relation: formValues.relation,
</SubMenuTopBarContainer> select: formValues.select,
multiSelect: formValues.multiSelect,
defaultValue: formValues.defaultValue,
}}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
</FormProvider>
); );
}; };

View File

@ -53,7 +53,6 @@ const customObjectMetadataItemEdge: ObjectEdge = {
createdAt: '2024-04-08T12:48:49.538Z', createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z', updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: null, defaultValue: null,
options: null,
relationDefinition: { relationDefinition: {
__typename: 'RelationDefinition', __typename: 'RelationDefinition',
direction: 'ONE_TO_MANY', direction: 'ONE_TO_MANY',
@ -114,7 +113,6 @@ const customObjectMetadataItemEdge: ObjectEdge = {
createdAt: '2024-04-08T12:48:49.538Z', createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z', updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: null, defaultValue: null,
options: null,
relationDefinition: null, relationDefinition: null,
fromRelationMetadata: null, fromRelationMetadata: null,
toRelationMetadata: null, toRelationMetadata: null,
@ -137,7 +135,6 @@ const customObjectMetadataItemEdge: ObjectEdge = {
createdAt: '2024-04-08T12:48:49.538Z', createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z', updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: null, defaultValue: null,
options: null,
relationDefinition: null, relationDefinition: null,
fromRelationMetadata: null, fromRelationMetadata: null,
toRelationMetadata: null, toRelationMetadata: null,
@ -160,7 +157,6 @@ const customObjectMetadataItemEdge: ObjectEdge = {
createdAt: '2024-04-08T12:48:49.538Z', createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z', updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: "''", defaultValue: "''",
options: null,
relationDefinition: null, relationDefinition: null,
fromRelationMetadata: null, fromRelationMetadata: null,
toRelationMetadata: null, toRelationMetadata: null,
@ -183,7 +179,6 @@ const customObjectMetadataItemEdge: ObjectEdge = {
createdAt: '2024-04-08T12:48:49.538Z', createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z', updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: 'now', defaultValue: 'now',
options: null,
relationDefinition: null, relationDefinition: null,
fromRelationMetadata: null, fromRelationMetadata: null,
toRelationMetadata: null, toRelationMetadata: null,
@ -206,7 +201,6 @@ const customObjectMetadataItemEdge: ObjectEdge = {
createdAt: '2024-04-08T12:48:49.538Z', createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z', updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: 'now', defaultValue: 'now',
options: null,
relationDefinition: null, relationDefinition: null,
fromRelationMetadata: null, fromRelationMetadata: null,
toRelationMetadata: null, toRelationMetadata: null,
@ -229,7 +223,6 @@ const customObjectMetadataItemEdge: ObjectEdge = {
createdAt: '2024-04-08T12:48:49.538Z', createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z', updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: 'uuid', defaultValue: 'uuid',
options: null,
relationDefinition: null, relationDefinition: null,
fromRelationMetadata: null, fromRelationMetadata: null,
toRelationMetadata: null, toRelationMetadata: null,