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:
Paul Rastoin
2026-07-31 18:35:16 +02:00
committed by GitHub
parent f663cd3c68
commit 4f8aaeaab0
9 changed files with 846 additions and 131 deletions
@@ -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',
]);
});
});
@@ -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}`,
),
];
}
}
};
@@ -1,12 +1,11 @@
import { Injectable } from '@nestjs/common';
import { msg, t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
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 MetadataUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-maps.type';
import { validateFlatEntityCircularDependency } from 'src/engine/workspace-manager/workspace-migration/utils/validate-flat-entity-circular-dependency.util';
@@ -22,96 +21,6 @@ const NAVIGATION_MENU_ITEM_MAX_DEPTH = 2;
@Injectable()
export class FlatNavigationMenuItemValidatorService {
private validateNavigationMenuItemType({
type,
hasTargetRecordId,
hasTargetObjectMetadataId,
hasViewId,
hasLink,
hasPageLayoutId,
name,
}: {
type: NavigationMenuItemType | null | undefined;
hasTargetRecordId: boolean;
hasTargetObjectMetadataId: boolean;
hasViewId: boolean;
hasLink: boolean;
hasPageLayoutId: boolean;
name: string | null | undefined;
}): FlatEntityValidationError<NavigationMenuItemExceptionCode>[] {
const errors: FlatEntityValidationError<NavigationMenuItemExceptionCode>[] =
[];
if (!isDefined(type)) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`Navigation menu item type is required`,
userFriendlyMessage: msg`Navigation menu item type is required`,
});
return errors;
}
switch (type) {
case NavigationMenuItemType.FOLDER:
if (!isDefined(name) || name.trim() === '') {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`Folder name is required`,
userFriendlyMessage: msg`Folder name is required`,
});
}
break;
case NavigationMenuItemType.OBJECT:
if (!hasTargetObjectMetadataId) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`targetObjectMetadataId is required for OBJECT type`,
userFriendlyMessage: msg`targetObjectMetadataId is required for OBJECT type`,
});
}
break;
case NavigationMenuItemType.VIEW:
if (!hasViewId) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`viewId is required for VIEW type`,
userFriendlyMessage: msg`viewId is required for VIEW type`,
});
}
break;
case NavigationMenuItemType.RECORD:
if (!hasTargetRecordId || !hasTargetObjectMetadataId) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`targetRecordId and targetObjectMetadataId are required for RECORD type`,
userFriendlyMessage: msg`targetRecordId and targetObjectMetadataId are required for RECORD type`,
});
}
break;
case NavigationMenuItemType.LINK:
if (!hasLink) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`link is required for LINK type`,
userFriendlyMessage: msg`link is required for LINK type`,
});
}
break;
case NavigationMenuItemType.PAGE_LAYOUT:
if (!hasPageLayoutId) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`pageLayoutId is required for PAGE_LAYOUT type`,
userFriendlyMessage: msg`pageLayoutId is required for PAGE_LAYOUT type`,
});
}
break;
}
return errors;
}
private getCircularDependencyValidationErrors({
navigationMenuItemUniversalIdentifier,
folderUniversalIdentifier,
@@ -173,6 +82,8 @@ export class FlatNavigationMenuItemValidatorService {
const validationResult = getEmptyFlatEntityValidationError({
flatEntityMinimalInformation: {
universalIdentifier: flatNavigationMenuItem.universalIdentifier,
name: flatNavigationMenuItem.name,
type: flatNavigationMenuItem.type,
},
metadataName: 'navigationMenuItem',
type: 'create',
@@ -189,23 +100,11 @@ export class FlatNavigationMenuItemValidatorService {
});
}
const typeValidationErrors = this.validateNavigationMenuItemType({
type: flatNavigationMenuItem.type,
hasTargetRecordId: isDefined(flatNavigationMenuItem.targetRecordId),
hasTargetObjectMetadataId: isDefined(
flatNavigationMenuItem.targetObjectMetadataUniversalIdentifier,
),
hasViewId: isDefined(flatNavigationMenuItem.viewUniversalIdentifier),
hasLink:
isDefined(flatNavigationMenuItem.link) &&
isNonEmptyString(flatNavigationMenuItem.link),
hasPageLayoutId: isDefined(
flatNavigationMenuItem.pageLayoutUniversalIdentifier,
),
name: flatNavigationMenuItem.name,
});
validationResult.errors.push(...typeValidationErrors);
validationResult.errors.push(
...validateNavigationMenuItemTypeRequiredProperties({
flatNavigationMenuItem,
}),
);
if (isDefined(flatNavigationMenuItem.folderUniversalIdentifier)) {
const circularDependencyErrors =
@@ -257,6 +156,8 @@ export class FlatNavigationMenuItemValidatorService {
const validationResult = getEmptyFlatEntityValidationError({
flatEntityMinimalInformation: {
universalIdentifier: flatEntityToValidate.universalIdentifier,
name: flatEntityToValidate.name,
type: flatEntityToValidate.type,
},
metadataName: 'navigationMenuItem',
type: 'delete',
@@ -295,6 +196,8 @@ export class FlatNavigationMenuItemValidatorService {
const validationResult = getEmptyFlatEntityValidationError({
flatEntityMinimalInformation: {
universalIdentifier,
name: flatEntityUpdate.name ?? fromFlatNavigationMenuItem?.name,
type: flatEntityUpdate.type ?? fromFlatNavigationMenuItem?.type,
},
metadataName: 'navigationMenuItem',
type: 'update',
@@ -325,23 +228,11 @@ export class FlatNavigationMenuItemValidatorService {
...flatEntityUpdate,
};
const nameUpdate = flatEntityUpdate.name;
const typeValidationErrors = this.validateNavigationMenuItemType({
type: toFlatNavigationMenuItem.type,
hasTargetRecordId: isDefined(toFlatNavigationMenuItem.targetRecordId),
hasTargetObjectMetadataId: isDefined(
toFlatNavigationMenuItem.targetObjectMetadataUniversalIdentifier,
),
hasViewId: isDefined(toFlatNavigationMenuItem.viewUniversalIdentifier),
hasLink: isNonEmptyString((toFlatNavigationMenuItem.link ?? '').trim()),
hasPageLayoutId: isDefined(
toFlatNavigationMenuItem.pageLayoutUniversalIdentifier,
),
name: isDefined(nameUpdate) ? nameUpdate : toFlatNavigationMenuItem.name,
});
validationResult.errors.push(...typeValidationErrors);
validationResult.errors.push(
...validateNavigationMenuItemTypeRequiredProperties({
flatNavigationMenuItem: toFlatNavigationMenuItem,
}),
);
const folderUniversalIdentifierUpdate =
flatEntityUpdate.folderUniversalIdentifier;