[REFACTOR] Split in two distinct forms Settings Object Model page (#10653)

# Introduction

This PR contains around ~+300 tests + snapshot additions
Please check both object model creation and edition

Closes https://github.com/twentyhq/core-team-issues/issues/355

Refactored into two agnostic forms the Object Model settings page for
instance `/settings/objects/notes#settings`.

## `SettingsDataModelObjectAboutForm`
Added a new abstraction `SettingsUpdateDataModelObjectAboutForm` to wrap
`SettingsDataModelObjectAboutForm` in an `update` context


![image](https://github.com/user-attachments/assets/137b4f85-d5d8-442f-ad81-27653af99c03)
Schema:
```ts
const requiredFormFields = objectMetadataItemSchema.pick({
  description: true,
  icon: true,
  labelPlural: true,
  labelSingular: true,
});
const optionalFormFields = objectMetadataItemSchema
  .pick({
    nameSingular: true,
    namePlural: true,
    isLabelSyncedWithName: true,
  })
  .partial();
export const settingsDataModelObjectAboutFormSchema =
  requiredFormFields.merge(optionalFormFields);
```
##  `SettingsDataModelObjectSettingsFormCard`
Update on change

![image](https://github.com/user-attachments/assets/179da504-7680-498d-818d-d7f80d77736b)
Schema:
```ts
export const settingsDataModelObjectIdentifiersFormSchema =
  objectMetadataItemSchema.pick({
    labelIdentifierFieldMetadataId: true,
    imageIdentifierFieldMetadataId: true,
  });
```

## Error management and validation schema
Improved the frontend validation form in order to attest that:
- Names are in camelCase
- Names are differents
- Names are not empty string ***SHOULD BE DONE SERVER SIDE TOO*** ( will
in a next PR, atm it literally breaks any workspace )
- Labels are differents
- Labels aren't empty strings

Hide the error messages as we need to decide what kind of styling we
want for our errors with forms
( Example with error labels )

![image](https://github.com/user-attachments/assets/d54534f8-8163-42d9-acdc-976a5e723498)
This commit is contained in:
Paul Rastoin
2025-03-07 10:14:25 +01:00
committed by GitHub
parent 21c7d2081d
commit 776632fe79
17 changed files with 646 additions and 382 deletions

View File

@ -0,0 +1,159 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`settingsDataModelObjectAboutFormSchema fails when isLabelSyncedWithName is not a boolean 1`] = `
[ZodError: [
{
"code": "invalid_type",
"expected": "boolean",
"received": "string",
"path": [
"isLabelSyncedWithName"
],
"message": "Expected boolean, received string"
}
]]
`;
exports[`settingsDataModelObjectAboutFormSchema fails when labels are empty strings 1`] = `
[ZodError: [
{
"code": "too_small",
"minimum": 1,
"type": "string",
"inclusive": true,
"exact": false,
"message": "String must contain at least 1 character(s)",
"path": [
"labelSingular"
]
},
{
"code": "too_small",
"minimum": 1,
"type": "string",
"inclusive": true,
"exact": false,
"message": "String must contain at least 1 character(s)",
"path": [
"labelPlural"
]
},
{
"code": "custom",
"message": "Singular and plural labels must be different",
"path": [
"labelPlural"
]
},
{
"code": "custom",
"message": "Singular and plural labels must be different",
"path": [
"labelSingular"
]
}
]]
`;
exports[`settingsDataModelObjectAboutFormSchema fails when names are not in camelCase 1`] = `
[ZodError: [
{
"code": "custom",
"message": "String should be camel case",
"path": [
"namePlural"
]
},
{
"code": "custom",
"message": "String should be camel case",
"path": [
"nameSingular"
]
}
]]
`;
exports[`settingsDataModelObjectAboutFormSchema fails when required fields are missing 1`] = `
[ZodError: [
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": [
"labelSingular"
],
"message": "Required"
},
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": [
"labelPlural"
],
"message": "Required"
}
]]
`;
exports[`settingsDataModelObjectAboutFormSchema fails when singular and plural labels are the same 1`] = `
[ZodError: [
{
"code": "custom",
"message": "Singular and plural labels must be different",
"path": [
"labelPlural"
]
},
{
"code": "custom",
"message": "Singular and plural labels must be different",
"path": [
"labelSingular"
]
}
]]
`;
exports[`settingsDataModelObjectAboutFormSchema fails when singular and plural names are the same 1`] = `
[ZodError: [
{
"code": "custom",
"message": "Singular and plural names must be different",
"path": [
"nameSingular"
]
},
{
"code": "custom",
"message": "Singular and plural names must be different",
"path": [
"namePlural"
]
}
]]
`;
exports[`settingsDataModelObjectAboutFormSchema fails with invalid types for optional fields 1`] = `
[ZodError: [
{
"code": "invalid_type",
"expected": "string",
"received": "number",
"path": [
"description"
],
"message": "Expected string, received number"
},
{
"code": "invalid_type",
"expected": "string",
"received": "boolean",
"path": [
"icon"
],
"message": "Expected string, received boolean"
}
]]
`;

View File

@ -1,51 +0,0 @@
import { SafeParseSuccess } from 'zod';
import { CreateObjectInput } from '~/generated-metadata/graphql';
import { settingsCreateObjectInputSchema } from '../settingsCreateObjectInputSchema';
describe('settingsCreateObjectInputSchema', () => {
it('validates a valid input and adds name properties', () => {
// Given
const validInput = {
description: 'A valid description',
icon: 'IconPlus',
labelPlural: ' Labels ',
labelSingular: 'Label ',
namePlural: 'namePlural',
nameSingular: 'nameSingular',
isLabelSyncedWithName: false,
};
// When
const result = settingsCreateObjectInputSchema.safeParse(validInput);
// Then
expect(result.success).toBe(true);
expect((result as SafeParseSuccess<CreateObjectInput>).data).toEqual({
description: validInput.description,
icon: validInput.icon,
labelPlural: 'Labels',
labelSingular: 'Label',
namePlural: 'namePlural',
nameSingular: 'nameSingular',
isLabelSyncedWithName: false,
});
});
it('fails for an invalid input', () => {
// Given
const invalidInput = {
description: 123,
icon: true,
labelPlural: [],
labelSingular: {},
};
// When
const result = settingsCreateObjectInputSchema.safeParse(invalidInput);
// Then
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,150 @@
import {
SettingsDataModelObjectAboutFormValues,
settingsDataModelObjectAboutFormSchema,
} from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema';
import { EachTestingContext } from '~/types/EachTestingContext';
describe('settingsDataModelObjectAboutFormSchema', () => {
const validInput: SettingsDataModelObjectAboutFormValues = {
description: 'A valid description',
icon: 'IconName',
labelPlural: 'Labels Plural',
labelSingular: 'Label Singular',
namePlural: 'labelsPlural',
nameSingular: 'labelSingular',
isLabelSyncedWithName: false,
};
const passingTestsUseCase: EachTestingContext<{
input: SettingsDataModelObjectAboutFormValues;
expectedSuccess: true;
}>[] = [
{
title: 'validates a complete valid input',
context: {
input: validInput,
expectedSuccess: true,
},
},
{
title: 'validates input with optional fields omitted',
context: {
input: {
labelPlural: 'Labels Plural',
labelSingular: 'Label Singular',
namePlural: 'labelsPlural',
nameSingular: 'labelSingular',
isLabelSyncedWithName: false,
},
expectedSuccess: true,
},
},
{
title: 'validates input with trimmed labels',
context: {
input: {
...validInput,
labelPlural: ' Labels Plural ',
labelSingular: ' Label Singular ',
},
expectedSuccess: true,
},
},
];
const failsValidationTestsUseCase: EachTestingContext<{
input: Partial<Record<keyof SettingsDataModelObjectAboutFormValues, any>>;
expectedSuccess: false;
}>[] = [
{
title: 'fails when required fields are missing',
context: {
input: {
description: 'Only description',
labelPlural: undefined,
labelSingular: undefined,
},
expectedSuccess: false,
},
},
{
title: 'fails when names are not in camelCase',
context: {
input: {
...validInput,
namePlural: 'Labels_Plural',
nameSingular: 'Label-Singular',
},
expectedSuccess: false,
},
},
{
title: 'fails when labels are empty strings',
context: {
input: {
...validInput,
labelPlural: '',
labelSingular: '',
},
expectedSuccess: false,
},
},
{
title: 'fails when singular and plural labels are the same',
context: {
input: {
...validInput,
labelPlural: 'Same Label',
labelSingular: 'Same Label',
},
expectedSuccess: false,
},
},
{
title: 'fails when singular and plural names are the same',
context: {
input: {
...validInput,
namePlural: 'sameName',
nameSingular: 'sameName',
},
expectedSuccess: false,
},
},
{
title: 'fails when isLabelSyncedWithName is not a boolean',
context: {
input: {
...validInput,
isLabelSyncedWithName: 'true',
},
expectedSuccess: false,
},
},
{
title: 'fails with invalid types for optional fields',
context: {
input: {
...validInput,
description: 123,
icon: true,
},
expectedSuccess: false,
},
},
];
test.each([...passingTestsUseCase, ...failsValidationTestsUseCase])(
'$title',
({ context: { expectedSuccess, input } }) => {
const result = settingsDataModelObjectAboutFormSchema.safeParse({
...validInput,
...input,
});
expect(result.success).toBe(expectedSuccess);
if (!expectedSuccess) {
expect(result.error).toMatchSnapshot();
}
},
);
});

View File

@ -1,52 +0,0 @@
import { SafeParseSuccess } from 'zod';
import { UpdateObjectPayload } from '~/generated-metadata/graphql';
import { settingsUpdateObjectInputSchema } from '../settingsUpdateObjectInputSchema';
describe('settingsUpdateObjectInputSchema', () => {
it('validates a valid input and adds name properties', () => {
// Given
const validInput = {
description: 'A valid description',
icon: 'IconName',
labelPlural: 'Labels Plural ',
labelSingular: ' Label Singular',
namePlural: 'namePlural',
nameSingular: 'nameSingular',
labelIdentifierFieldMetadataId: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
};
// When
const result = settingsUpdateObjectInputSchema.safeParse(validInput);
// Then
expect(result.success).toBe(true);
expect((result as SafeParseSuccess<UpdateObjectPayload>).data).toEqual({
description: validInput.description,
icon: validInput.icon,
labelIdentifierFieldMetadataId: validInput.labelIdentifierFieldMetadataId,
labelPlural: 'Labels Plural',
labelSingular: 'Label Singular',
namePlural: 'namePlural',
nameSingular: 'nameSingular',
});
});
it('fails for an invalid input', () => {
// Given
const invalidInput = {
description: 123,
icon: true,
labelPlural: [],
labelSingular: {},
labelIdentifierFieldMetadataId: 'invalid uuid',
};
// When
const result = settingsUpdateObjectInputSchema.safeParse(invalidInput);
// Then
expect(result.success).toBe(false);
});
});

View File

@ -1,16 +0,0 @@
import { settingsDataModelObjectAboutFormSchema } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
import { CreateObjectInput } from '~/generated-metadata/graphql';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
export const settingsCreateObjectInputSchema =
settingsDataModelObjectAboutFormSchema.transform<CreateObjectInput>(
(values) => ({
...values,
nameSingular:
values.nameSingular ??
computeMetadataNameFromLabel(values.labelSingular),
namePlural:
values.namePlural ?? computeMetadataNameFromLabel(values.labelPlural),
isLabelSyncedWithName: values.isLabelSyncedWithName ?? true,
}),
);

View File

@ -0,0 +1,68 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { t } from '@lingui/core/macro';
import { ZodType, z } from 'zod';
import { ReadonlyKeysArray } from '~/types/ReadonlyKeysArray';
import { zodNonEmptyString } from '~/types/ZodNonEmptyString';
import { camelCaseStringSchema } from '~/utils/validation-schemas/camelCaseStringSchema';
type ZodTypeSettingsDataModelFormFields = ZodType<
Pick<
ObjectMetadataItem,
| 'labelSingular'
| 'labelPlural'
| 'description'
| 'icon'
| 'namePlural'
| 'nameSingular'
| 'isLabelSyncedWithName'
>
>;
const settingsDataModelFormFieldsSchema = z.object({
description: z.string().nullish(),
icon: z.string().optional(),
labelSingular: zodNonEmptyString,
labelPlural: zodNonEmptyString,
namePlural: zodNonEmptyString.and(camelCaseStringSchema),
nameSingular: zodNonEmptyString.and(camelCaseStringSchema),
isLabelSyncedWithName: z.boolean(),
}) satisfies ZodTypeSettingsDataModelFormFields;
export const settingsDataModelObjectAboutFormSchema =
settingsDataModelFormFieldsSchema.superRefine(
({ labelPlural, labelSingular, namePlural, nameSingular }, ctx) => {
const labelsAreDifferent =
labelPlural.trim().toLowerCase() !== labelSingular.trim().toLowerCase();
if (!labelsAreDifferent) {
const labelFields: ReadonlyKeysArray<ObjectMetadataItem> = [
'labelPlural',
'labelSingular',
];
labelFields.forEach((field) =>
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t`Singular and plural labels must be different`,
path: [field],
}),
);
}
const nameAreDifferent =
nameSingular.toLowerCase() !== namePlural.toLowerCase();
if (!nameAreDifferent) {
const nameFields: ReadonlyKeysArray<ObjectMetadataItem> = [
'nameSingular',
'namePlural',
];
nameFields.forEach((field) =>
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t`Singular and plural names must be different`,
path: [field],
}),
);
}
},
);
export type SettingsDataModelObjectAboutFormValues = z.infer<
typeof settingsDataModelObjectAboutFormSchema
>;

View File

@ -1,13 +0,0 @@
import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema';
import { settingsDataModelObjectAboutFormSchema } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
export const settingsUpdateObjectInputSchema =
settingsDataModelObjectAboutFormSchema
.merge(
objectMetadataItemSchema.pick({
imageIdentifierFieldMetadataId: true,
isActive: true,
labelIdentifierFieldMetadataId: true,
}),
)
.partial();