refactor(navigation-menu-item): validate universal properties instead of ids (#23566)
Follow-up to the discussion on #23485 and closes https://github.com/twentyhq/twenty/issues/23484. `FlatNavigationMenuItemValidatorService` receives `UniversalFlatEntityValidationArgs<'navigationMenuItem'>`, so the entity it validates is a `UniversalFlatNavigationMenuItem`: `viewId`, `pageLayoutId` and `targetObjectMetadataId` do not exist at that scope. The validator read the right universal keys but passed them through a bag of booleans named after the ids (`hasViewId`, `hasPageLayoutId`, ...) and then reported the id names in its errors. Nothing tied a message to the property it checked, so fixing one message string leaves the other five wrong. ## Changes - Replace the private `validateNavigationMenuItemType` boolean bag with `validateNavigationMenuItemTypeRequiredProperties({ flatNavigationMenuItem })` under `flat-navigation-menu-item/validators/utils/`, in line with `validateAgentRequiredProperties` and `validateNavigationMenuItemPageLayoutReferenceCrossEntity`. It takes the universal entity, so a message can only name a property that exists at that scope. - The util is an explicit `switch` on `NavigationMenuItemType` closed by `assertUnreachable`, so adding a type fails to compile until its contract is declared. - Each case validates its own properties instead of checking presence generically: - `FOLDER`: non blank `name` - `OBJECT`, `VIEW`, `PAGE_LAYOUT`: `targetObjectMetadataUniversalIdentifier` / `viewUniversalIdentifier` / `pageLayoutUniversalIdentifier` must be valid uuids - `RECORD`: `targetRecordId` and `targetObjectMetadataUniversalIdentifier`, both uuids, reported separately - `LINK`: `link` must pass `isValidUrl` - Both call sites spread the result; the update path passes the merged `{ ...from, ...update }` entity, which removes the redundant `name` re-merge. `targetRecordId` stays an id: it points at workspace record data rather than metadata, so it has no universal counterpart. ## Behaviour - Errors name the universal property (`viewUniversalIdentifier`) instead of the id (`viewId`). - Blank strings are now uniformly treated as missing; creation previously accepted `link: " "`. - `RECORD` reports each missing property separately instead of one merged error. - Values that are present but malformed are now rejected: non uuid identifiers and links that are not urls. Standard application identifiers are all v4 uuids and the create/update inputs already carry `@IsUUID`, so this only tightens the app manifest path. ## Verification - Unit tests for the util cover each type valid and invalid, blank names, non url links and non uuid identifiers (23 tests pass alongside the sibling suite) - `nx typecheck twenty-server` clean - oxlint (type-aware) and oxfmt clean on the changed files <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23566?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+184
@@ -0,0 +1,184 @@
|
||||
import { NavigationMenuItemType } from 'twenty-shared/types';
|
||||
|
||||
import { validateNavigationMenuItemTypeRequiredProperties } from 'src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/validate-navigation-menu-item-type-required-properties.util';
|
||||
import { NavigationMenuItemExceptionCode } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception';
|
||||
import { type UniversalFlatNavigationMenuItem } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-navigation-menu-item.type';
|
||||
|
||||
const VALID_UUID = '20202020-b001-4b01-8b01-c0aba11c0001';
|
||||
const OTHER_VALID_UUID = '20202020-b002-4b02-8b02-c0aba11c0002';
|
||||
|
||||
const buildFlatNavigationMenuItem = (
|
||||
overrides: Partial<UniversalFlatNavigationMenuItem>,
|
||||
): UniversalFlatNavigationMenuItem =>
|
||||
({
|
||||
universalIdentifier: VALID_UUID,
|
||||
name: null,
|
||||
link: null,
|
||||
icon: null,
|
||||
color: null,
|
||||
position: 0,
|
||||
targetRecordId: null,
|
||||
userWorkspaceId: null,
|
||||
folderUniversalIdentifier: null,
|
||||
viewUniversalIdentifier: null,
|
||||
pageLayoutUniversalIdentifier: null,
|
||||
targetObjectMetadataUniversalIdentifier: null,
|
||||
...overrides,
|
||||
}) as UniversalFlatNavigationMenuItem;
|
||||
|
||||
describe('validateNavigationMenuItemTypeRequiredProperties', () => {
|
||||
it('should return an error when type is not defined', () => {
|
||||
const errors = validateNavigationMenuItemTypeRequiredProperties({
|
||||
flatNavigationMenuItem: buildFlatNavigationMenuItem({
|
||||
type: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toMatchObject({
|
||||
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
|
||||
message: 'Navigation menu item type is required',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
type: NavigationMenuItemType.FOLDER,
|
||||
expectedMessages: ['A name is required for FOLDER type'],
|
||||
},
|
||||
{
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
expectedMessages: [
|
||||
'A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
expectedMessages: [
|
||||
'A valid viewUniversalIdentifier is required for VIEW type',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: NavigationMenuItemType.RECORD,
|
||||
expectedMessages: [
|
||||
'A valid targetRecordId is required for RECORD type',
|
||||
'A valid targetObjectMetadataUniversalIdentifier is required for RECORD type',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: NavigationMenuItemType.LINK,
|
||||
expectedMessages: ['A valid link is required for LINK type'],
|
||||
},
|
||||
{
|
||||
type: NavigationMenuItemType.PAGE_LAYOUT,
|
||||
expectedMessages: [
|
||||
'A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type',
|
||||
],
|
||||
},
|
||||
])(
|
||||
'should report every missing property for $type type',
|
||||
({ type, expectedMessages }) => {
|
||||
const errors = validateNavigationMenuItemTypeRequiredProperties({
|
||||
flatNavigationMenuItem: buildFlatNavigationMenuItem({ type }),
|
||||
});
|
||||
|
||||
expect(errors.map(({ message }) => message)).toEqual(expectedMessages);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
type: NavigationMenuItemType.FOLDER,
|
||||
overrides: { name: 'My folder' },
|
||||
},
|
||||
{
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
overrides: { targetObjectMetadataUniversalIdentifier: VALID_UUID },
|
||||
},
|
||||
{
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
overrides: { viewUniversalIdentifier: VALID_UUID },
|
||||
},
|
||||
{
|
||||
type: NavigationMenuItemType.RECORD,
|
||||
overrides: {
|
||||
targetRecordId: VALID_UUID,
|
||||
targetObjectMetadataUniversalIdentifier: OTHER_VALID_UUID,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NavigationMenuItemType.LINK,
|
||||
overrides: { link: 'https://twenty.com' },
|
||||
},
|
||||
{
|
||||
type: NavigationMenuItemType.PAGE_LAYOUT,
|
||||
overrides: { pageLayoutUniversalIdentifier: VALID_UUID },
|
||||
},
|
||||
])(
|
||||
'should not report any error when $type type properties are valid',
|
||||
({ type, overrides }) => {
|
||||
const errors = validateNavigationMenuItemTypeRequiredProperties({
|
||||
flatNavigationMenuItem: buildFlatNavigationMenuItem({
|
||||
type,
|
||||
...overrides,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('should return an error when type is unknown', () => {
|
||||
const errors = validateNavigationMenuItemTypeRequiredProperties({
|
||||
flatNavigationMenuItem: buildFlatNavigationMenuItem({
|
||||
type: 'UNKNOWN_NAVIGATION_MENU_ITEM_TYPE' as NavigationMenuItemType,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toMatchObject({
|
||||
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
|
||||
message:
|
||||
'Unknown navigation menu item type UNKNOWN_NAVIGATION_MENU_ITEM_TYPE',
|
||||
});
|
||||
});
|
||||
|
||||
it('should treat blank folder names as missing', () => {
|
||||
const errors = validateNavigationMenuItemTypeRequiredProperties({
|
||||
flatNavigationMenuItem: buildFlatNavigationMenuItem({
|
||||
type: NavigationMenuItemType.FOLDER,
|
||||
name: ' ',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(errors.map(({ message }) => message)).toEqual([
|
||||
'A name is required for FOLDER type',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should report an error when the link is not a valid url', () => {
|
||||
const errors = validateNavigationMenuItemTypeRequiredProperties({
|
||||
flatNavigationMenuItem: buildFlatNavigationMenuItem({
|
||||
type: NavigationMenuItemType.LINK,
|
||||
link: 'not a link',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(errors.map(({ message }) => message)).toEqual([
|
||||
'A valid link is required for LINK type',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should report an error when a universal identifier is not a valid uuid', () => {
|
||||
const errors = validateNavigationMenuItemTypeRequiredProperties({
|
||||
flatNavigationMenuItem: buildFlatNavigationMenuItem({
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewUniversalIdentifier: 'not-a-uuid',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(errors.map(({ message }) => message)).toEqual([
|
||||
'A valid viewUniversalIdentifier is required for VIEW type',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg, t } from '@lingui/core/macro';
|
||||
import {
|
||||
type AssertUnreachable,
|
||||
NavigationMenuItemType,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined, isValidUrl, isValidUuid } from 'twenty-shared/utils';
|
||||
|
||||
import { NavigationMenuItemExceptionCode } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception';
|
||||
import { type UniversalFlatNavigationMenuItem } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-navigation-menu-item.type';
|
||||
import { type FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
|
||||
|
||||
type NavigationMenuItemValidationError =
|
||||
FlatEntityValidationError<NavigationMenuItemExceptionCode>;
|
||||
|
||||
const buildInvalidInputError = (
|
||||
message: string,
|
||||
userFriendlyMessage: MessageDescriptor,
|
||||
): NavigationMenuItemValidationError => ({
|
||||
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
|
||||
message,
|
||||
userFriendlyMessage,
|
||||
});
|
||||
|
||||
const validateUuidProperty = ({
|
||||
value,
|
||||
message,
|
||||
userFriendlyMessage,
|
||||
}: {
|
||||
value: string | null | undefined;
|
||||
message: string;
|
||||
userFriendlyMessage: MessageDescriptor;
|
||||
}): NavigationMenuItemValidationError[] =>
|
||||
isDefined(value) && isValidUuid(value)
|
||||
? []
|
||||
: [buildInvalidInputError(message, userFriendlyMessage)];
|
||||
|
||||
export const validateNavigationMenuItemTypeRequiredProperties = ({
|
||||
flatNavigationMenuItem,
|
||||
}: {
|
||||
flatNavigationMenuItem: UniversalFlatNavigationMenuItem;
|
||||
}): NavigationMenuItemValidationError[] => {
|
||||
const {
|
||||
type,
|
||||
name,
|
||||
link,
|
||||
targetRecordId,
|
||||
targetObjectMetadataUniversalIdentifier,
|
||||
viewUniversalIdentifier,
|
||||
pageLayoutUniversalIdentifier,
|
||||
} = flatNavigationMenuItem;
|
||||
|
||||
if (!isDefined(type)) {
|
||||
return [
|
||||
buildInvalidInputError(
|
||||
t`Navigation menu item type is required`,
|
||||
msg`Navigation menu item type is required`,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case NavigationMenuItemType.FOLDER: {
|
||||
return isDefined(name) && name.trim() !== ''
|
||||
? []
|
||||
: [
|
||||
buildInvalidInputError(
|
||||
t`A name is required for FOLDER type`,
|
||||
msg`A name is required for FOLDER type`,
|
||||
),
|
||||
];
|
||||
}
|
||||
case NavigationMenuItemType.OBJECT: {
|
||||
return validateUuidProperty({
|
||||
value: targetObjectMetadataUniversalIdentifier,
|
||||
message: t`A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type`,
|
||||
userFriendlyMessage: msg`A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type`,
|
||||
});
|
||||
}
|
||||
case NavigationMenuItemType.VIEW: {
|
||||
return validateUuidProperty({
|
||||
value: viewUniversalIdentifier,
|
||||
message: t`A valid viewUniversalIdentifier is required for VIEW type`,
|
||||
userFriendlyMessage: msg`A valid viewUniversalIdentifier is required for VIEW type`,
|
||||
});
|
||||
}
|
||||
case NavigationMenuItemType.RECORD: {
|
||||
return [
|
||||
...validateUuidProperty({
|
||||
value: targetRecordId,
|
||||
message: t`A valid targetRecordId is required for RECORD type`,
|
||||
userFriendlyMessage: msg`A valid targetRecordId is required for RECORD type`,
|
||||
}),
|
||||
...validateUuidProperty({
|
||||
value: targetObjectMetadataUniversalIdentifier,
|
||||
message: t`A valid targetObjectMetadataUniversalIdentifier is required for RECORD type`,
|
||||
userFriendlyMessage: msg`A valid targetObjectMetadataUniversalIdentifier is required for RECORD type`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
case NavigationMenuItemType.LINK: {
|
||||
return isDefined(link) && isValidUrl(link)
|
||||
? []
|
||||
: [
|
||||
buildInvalidInputError(
|
||||
t`A valid link is required for LINK type`,
|
||||
msg`A valid link is required for LINK type`,
|
||||
),
|
||||
];
|
||||
}
|
||||
case NavigationMenuItemType.PAGE_LAYOUT: {
|
||||
return validateUuidProperty({
|
||||
value: pageLayoutUniversalIdentifier,
|
||||
message: t`A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type`,
|
||||
userFriendlyMessage: msg`A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type`,
|
||||
});
|
||||
}
|
||||
default: {
|
||||
// oxlint-disable-next-line unused-imports/no-unused-vars
|
||||
type UnhandledNavigationMenuItemType = AssertUnreachable<typeof type>;
|
||||
|
||||
return [
|
||||
buildInvalidInputError(
|
||||
t`Unknown navigation menu item type ${type}`,
|
||||
msg`Unknown navigation menu item type ${type}`,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user