refactor: use react-hook-form to validate Settings/DataModel/Field (#4916)
Closes #4295
This commit is contained in:
@ -4,8 +4,7 @@ import { act, renderHook } from '@testing-library/react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import { useCreateOneRelationMetadataItem } from '@/object-metadata/hooks/useCreateOneRelationMetadataItem';
|
||||
import { FieldMetadataType } from '~/generated/graphql';
|
||||
import { RelationMetadataType } from '~/generated-metadata/graphql';
|
||||
import { RelationMetadataType } from '~/generated/graphql';
|
||||
|
||||
import {
|
||||
query,
|
||||
@ -46,7 +45,6 @@ describe('useCreateOneRelationMetadataItem', () => {
|
||||
relationType: RelationMetadataType.OneToOne,
|
||||
field: {
|
||||
label: 'label',
|
||||
type: FieldMetadataType.Relation,
|
||||
},
|
||||
objectMetadataId: 'objectMetadataId',
|
||||
connect: {
|
||||
|
||||
@ -9,7 +9,7 @@ import { formatFieldMetadataItemInput } from './formatFieldMetadataItemInput';
|
||||
|
||||
export type FormatRelationMetadataInputParams = {
|
||||
relationType: RelationType;
|
||||
field: Pick<Field, 'label' | 'icon' | 'description' | 'type'>;
|
||||
field: Pick<Field, 'label' | 'icon' | 'description'>;
|
||||
objectMetadataId: string;
|
||||
connect: {
|
||||
field: Pick<Field, 'label' | 'icon'>;
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
import { SafeParseSuccess } from 'zod';
|
||||
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { mockedCompanyObjectMetadataItem } from '~/testing/mock-data/metadata';
|
||||
|
||||
import { objectMetadataItemSchema } from '../objectMetadataItemSchema';
|
||||
@ -11,13 +8,10 @@ describe('objectMetadataItemSchema', () => {
|
||||
const validObjectMetadataItem = mockedCompanyObjectMetadataItem;
|
||||
|
||||
// When
|
||||
const result = objectMetadataItemSchema.safeParse(validObjectMetadataItem);
|
||||
const result = objectMetadataItemSchema.parse(validObjectMetadataItem);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(true);
|
||||
expect((result as SafeParseSuccess<ObjectMetadataItem>).data).toEqual(
|
||||
validObjectMetadataItem,
|
||||
);
|
||||
expect(result).toEqual(validObjectMetadataItem);
|
||||
});
|
||||
|
||||
it('fails for an invalid object metadata item', () => {
|
||||
|
||||
@ -1,6 +1,104 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
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.ZodType<FieldMetadataItem> = z.any();
|
||||
export const fieldMetadataItemSchema = z.object({
|
||||
__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>;
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const metadataLabelSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.regex(/^[a-zA-Z][a-zA-Z0-9 ()]*$/);
|
||||
@ -2,6 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema';
|
||||
import { metadataLabelSchema } from '@/object-metadata/validation-schemas/metadataLabelSchema';
|
||||
import { camelCaseStringSchema } from '~/utils/validation-schemas/camelCaseStringSchema';
|
||||
|
||||
export const objectMetadataItemSchema = z.object({
|
||||
@ -18,8 +19,8 @@ export const objectMetadataItemSchema = z.object({
|
||||
isRemote: z.boolean(),
|
||||
isSystem: z.boolean(),
|
||||
labelIdentifierFieldMetadataId: z.string().uuid().nullable(),
|
||||
labelPlural: z.string().trim().min(1),
|
||||
labelSingular: z.string().trim().min(1),
|
||||
labelPlural: metadataLabelSchema,
|
||||
labelSingular: metadataLabelSchema,
|
||||
namePlural: camelCaseStringSchema,
|
||||
nameSingular: camelCaseStringSchema,
|
||||
updatedAt: z.string().datetime(),
|
||||
|
||||
@ -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>
|
||||
);
|
||||
@ -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',
|
||||
},
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -1,5 +1,7 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { FieldMetadataType } from '~/generated/graphql';
|
||||
|
||||
import { useFieldMetadataForm } from '../useFieldMetadataForm';
|
||||
|
||||
describe('useFieldMetadataForm', () => {
|
||||
@ -14,8 +16,6 @@ describe('useFieldMetadataForm', () => {
|
||||
|
||||
expect(result.current.isInitialized).toBe(true);
|
||||
expect(result.current.formValues).toEqual({
|
||||
icon: 'IconUsers',
|
||||
label: '',
|
||||
type: 'TEXT',
|
||||
currency: { currencyCode: 'USD' },
|
||||
relation: {
|
||||
@ -45,7 +45,7 @@ describe('useFieldMetadataForm', () => {
|
||||
expect(result.current.hasSelectFormChanged).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.handleFormChange({ label: 'New Label' });
|
||||
result.current.handleFormChange({ type: FieldMetadataType.Number });
|
||||
});
|
||||
|
||||
expect(result.current.hasFieldFormChanged).toBe(true);
|
||||
|
||||
@ -15,16 +15,11 @@ import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
||||
import { SettingsDataModelFieldSettingsFormValues } from '../components/SettingsDataModelFieldSettingsFormCard';
|
||||
|
||||
type FormValues = {
|
||||
description?: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
defaultValue: any;
|
||||
type: SettingsSupportedFieldType;
|
||||
} & SettingsDataModelFieldSettingsFormValues;
|
||||
|
||||
export const fieldMetadataFormDefaultValues: FormValues = {
|
||||
icon: 'IconUsers',
|
||||
label: '',
|
||||
type: FieldMetadataType.Text,
|
||||
currency: { currencyCode: CurrencyCode.USD },
|
||||
relation: {
|
||||
@ -43,9 +38,6 @@ const relationTargetFieldSchema = z.object({
|
||||
defaultValue: z.any(),
|
||||
});
|
||||
const fieldSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
icon: z.string().startsWith('Icon'),
|
||||
label: z.string().min(1),
|
||||
defaultValue: z.any(),
|
||||
type: z.enum(
|
||||
Object.values(FieldMetadataType) as [
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
import { settingsDataModelFieldAboutFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldAboutForm';
|
||||
|
||||
export const settingsFieldFormSchema = settingsDataModelFieldAboutFormSchema;
|
||||
Reference in New Issue
Block a user