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": [
"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": {
"docs": { "scope": "ui-docs" },

View File

@ -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: {

View File

@ -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'>;

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 { 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', () => {

View File

@ -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>;

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 { 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(),

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 { 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);

View File

@ -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 [

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 { FormProvider, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import { isNonEmptyString } from '@sniptt/guards';
import { IconArchive, IconSettings } from 'twenty-ui';
import { z } from 'zod';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
@ -14,10 +17,11 @@ import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
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 { 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';
@ -31,6 +35,10 @@ import {
RelationMetadataType,
} from '~/generated-metadata/graphql';
type SettingsDataModelFieldEditFormValues = z.infer<
typeof settingsFieldFormSchema
>;
const StyledSettingsObjectFieldTypeSelect = styled(
SettingsDataModelFieldTypeSelect,
)`
@ -80,6 +88,11 @@ export const SettingsObjectFieldEdit = () => {
[activeMetadataField, getRelationMetadata],
) ?? {};
const formConfig = useForm<SettingsDataModelFieldEditFormValues>({
mode: 'onTouched',
resolver: zodResolver(settingsFieldFormSchema),
});
const {
formValues,
handleFormChange,
@ -130,9 +143,6 @@ export const SettingsObjectFieldEdit = () => {
if (!isFieldTypeSupported) return;
initForm({
icon: activeMetadataField.icon ?? undefined,
label: activeMetadataField.label,
description: activeMetadataField.description ?? undefined,
type: fieldType,
...(currencyDefaultValue ? { currency: currencyDefaultValue } : {}),
relation: {
@ -163,7 +173,10 @@ export const SettingsObjectFieldEdit = () => {
if (!isInitialized || !activeObjectMetadataItem || !activeMetadataField)
return null;
const canSave = isValid && hasFormChanged;
const canSave =
formConfig.formState.isValid &&
isValid &&
(formConfig.formState.isDirty || hasFormChanged);
const isLabelIdentifier = isLabelIdentifierField({
fieldMetadataItem: activeMetadataField,
@ -173,6 +186,9 @@ export const SettingsObjectFieldEdit = () => {
const handleSave = async () => {
if (!validatedFormValues) return;
const formValues = formConfig.getValues();
const { dirtyFields } = formConfig.formState;
try {
if (
validatedFormValues.type === FieldMetadataType.Relation &&
@ -186,17 +202,17 @@ export const SettingsObjectFieldEdit = () => {
type: validatedFormValues.type,
});
}
if (
Object.keys(dirtyFields).length > 0 ||
hasFieldFormChanged ||
hasSelectFormChanged ||
hasMultiSelectFormChanged ||
hasDefaultValueChanged
) {
await editMetadataField({
description: validatedFormValues.description,
icon: validatedFormValues.icon,
...formValues,
id: activeMetadataField.id,
label: validatedFormValues.label,
defaultValue: validatedFormValues.defaultValue,
type: validatedFormValues.type,
options:
@ -225,77 +241,83 @@ export const SettingsObjectFieldEdit = () => {
canPersistFieldMetadataItemUpdate(activeMetadataField);
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{
children: activeObjectMetadataItem.labelPlural,
href: `/settings/objects/${objectSlug}`,
},
{ children: activeMetadataField.label },
]}
/>
{shouldDisplaySaveAndCancel && (
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
onSave={handleSave}
// eslint-disable-next-line react/jsx-props-no-spreading
<FormProvider {...formConfig}>
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{
children: activeObjectMetadataItem.labelPlural,
href: `/settings/objects/${objectSlug}`,
},
{ children: activeMetadataField.label },
]}
/>
)}
</SettingsHeaderContainer>
<SettingsObjectFieldFormSection
disabled={!activeMetadataField.isCustom}
name={formValues.label}
description={formValues.description}
iconKey={formValues.icon}
onChange={handleFormChange}
/>
<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 && (
{shouldDisplaySaveAndCancel && (
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
onSave={handleSave}
/>
)}
</SettingsHeaderContainer>
<Section>
<H2Title title="Danger zone" description="Disable this field" />
<Button
Icon={IconArchive}
title="Disable"
size="small"
onClick={handleDisable}
<H2Title
title="Name and description"
description="The name and description of this field"
/>
<SettingsDataModelFieldAboutForm
disabled={!activeMetadataField.isCustom}
fieldMetadataItem={activeMetadataField}
/>
</Section>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
<Section>
<H2Title
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 { FormProvider, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import { Reference, useApolloClient } from '@apollo/client';
import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import pick from 'lodash.pick';
import { IconSettings } from 'twenty-ui';
import { z } from 'zod';
import { useCreateOneRelationMetadataItem } from '@/object-metadata/hooks/useCreateOneRelationMetadataItem';
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 { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
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 { 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';
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 { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
type SettingsDataModelNewFieldFormValues = z.infer<
typeof settingsFieldFormSchema
>;
const StyledSettingsObjectFieldTypeSelect = styled(
SettingsDataModelFieldTypeSelect,
)`
@ -57,10 +66,15 @@ export const SettingsObjectNewFieldStep2 = () => {
formValues,
handleFormChange,
initForm,
isValid: canSave,
isValid,
validatedFormValues,
} = useFieldMetadataForm();
const formConfig = useForm<SettingsDataModelNewFieldFormValues>({
mode: 'onTouched',
resolver: zodResolver(settingsFieldFormSchema),
});
useEffect(() => {
if (!activeObjectMetadataItem) {
navigate(AppPath.NotFound);
@ -121,19 +135,18 @@ export const SettingsObjectNewFieldStep2 = () => {
if (!activeObjectMetadataItem) return null;
const canSave = formConfig.formState.isValid && isValid;
const handleSave = async () => {
if (!validatedFormValues) return;
const formValues = formConfig.getValues();
try {
if (validatedFormValues.type === FieldMetadataType.Relation) {
const createdRelation = await createOneRelationMetadata({
relationType: validatedFormValues.relation.type,
field: {
description: validatedFormValues.description,
icon: validatedFormValues.icon,
label: validatedFormValues.label,
type: validatedFormValues.type,
},
field: pick(formValues, ['icon', 'label', 'description']),
objectMetadataId: activeObjectMetadataItem.id,
connect: {
field: {
@ -223,9 +236,7 @@ export const SettingsObjectNewFieldStep2 = () => {
currencyCode: validatedFormValues.currency.currencyCode,
}
: validatedFormValues.defaultValue,
description: validatedFormValues.description,
icon: validatedFormValues.icon,
label: validatedFormValues.label ?? '',
...formValues,
objectMetadataId: activeObjectMetadataItem.id,
type: validatedFormValues.type,
options:
@ -287,61 +298,65 @@ export const SettingsObjectNewFieldStep2 = () => {
];
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{
children: activeObjectMetadataItem.labelPlural,
href: `/settings/objects/${objectSlug}`,
},
{ children: 'New Field' },
]}
/>
{!activeObjectMetadataItem.isRemote && (
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
onSave={handleSave}
// eslint-disable-next-line react/jsx-props-no-spreading
<FormProvider {...formConfig}>
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Objects', href: '/settings/objects' },
{
children: activeObjectMetadataItem.labelPlural,
href: `/settings/objects/${objectSlug}`,
},
{ children: 'New Field' },
]}
/>
)}
</SettingsHeaderContainer>
<SettingsObjectFieldFormSection
iconKey={formValues.icon}
name={formValues.label}
description={formValues.description}
onChange={handleFormChange}
/>
<Section>
<H2Title
title="Type and values"
description="The field's type and values."
/>
<StyledSettingsObjectFieldTypeSelect
excludedFieldTypes={excludedFieldTypes}
onChange={handleFormChange}
value={formValues.type}
/>
<SettingsDataModelFieldSettingsFormCard
fieldMetadataItem={{
icon: formValues.icon,
label: formValues.label || 'Employees',
type: formValues.type,
}}
objectMetadataItem={activeObjectMetadataItem}
onChange={handleFormChange}
values={{
currency: formValues.currency,
relation: formValues.relation,
select: formValues.select,
multiSelect: formValues.multiSelect,
defaultValue: formValues.defaultValue,
}}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
{!activeObjectMetadataItem.isRemote && (
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
onSave={handleSave}
/>
)}
</SettingsHeaderContainer>
<Section>
<H2Title
title="Name and description"
description="The name and description of this field"
/>
<SettingsDataModelFieldAboutForm />
</Section>
<Section>
<H2Title
title="Type and values"
description="The field's type and values."
/>
<StyledSettingsObjectFieldTypeSelect
excludedFieldTypes={excludedFieldTypes}
onChange={handleFormChange}
value={formValues.type}
/>
<SettingsDataModelFieldSettingsFormCard
fieldMetadataItem={{
icon: formConfig.watch('icon'),
label: formConfig.watch('label') || 'Employees',
type: formValues.type,
}}
objectMetadataItem={activeObjectMetadataItem}
onChange={handleFormChange}
values={{
currency: formValues.currency,
relation: formValues.relation,
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',
updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: null,
options: null,
relationDefinition: {
__typename: 'RelationDefinition',
direction: 'ONE_TO_MANY',
@ -114,7 +113,6 @@ const customObjectMetadataItemEdge: ObjectEdge = {
createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: null,
options: null,
relationDefinition: null,
fromRelationMetadata: null,
toRelationMetadata: null,
@ -137,7 +135,6 @@ const customObjectMetadataItemEdge: ObjectEdge = {
createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: null,
options: null,
relationDefinition: null,
fromRelationMetadata: null,
toRelationMetadata: null,
@ -160,7 +157,6 @@ const customObjectMetadataItemEdge: ObjectEdge = {
createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: "''",
options: null,
relationDefinition: null,
fromRelationMetadata: null,
toRelationMetadata: null,
@ -183,7 +179,6 @@ const customObjectMetadataItemEdge: ObjectEdge = {
createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: 'now',
options: null,
relationDefinition: null,
fromRelationMetadata: null,
toRelationMetadata: null,
@ -206,7 +201,6 @@ const customObjectMetadataItemEdge: ObjectEdge = {
createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: 'now',
options: null,
relationDefinition: null,
fromRelationMetadata: null,
toRelationMetadata: null,
@ -229,7 +223,6 @@ const customObjectMetadataItemEdge: ObjectEdge = {
createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: 'uuid',
options: null,
relationDefinition: null,
fromRelationMetadata: null,
toRelationMetadata: null,