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 { Injectable } from '@nestjs/common';
import { msg, t } from '@lingui/core/macro'; import { msg, t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; 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 { 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 { 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'; 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() @Injectable()
export class FlatNavigationMenuItemValidatorService { 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({ private getCircularDependencyValidationErrors({
navigationMenuItemUniversalIdentifier, navigationMenuItemUniversalIdentifier,
folderUniversalIdentifier, folderUniversalIdentifier,
@@ -173,6 +82,8 @@ export class FlatNavigationMenuItemValidatorService {
const validationResult = getEmptyFlatEntityValidationError({ const validationResult = getEmptyFlatEntityValidationError({
flatEntityMinimalInformation: { flatEntityMinimalInformation: {
universalIdentifier: flatNavigationMenuItem.universalIdentifier, universalIdentifier: flatNavigationMenuItem.universalIdentifier,
name: flatNavigationMenuItem.name,
type: flatNavigationMenuItem.type,
}, },
metadataName: 'navigationMenuItem', metadataName: 'navigationMenuItem',
type: 'create', type: 'create',
@@ -189,23 +100,11 @@ export class FlatNavigationMenuItemValidatorService {
}); });
} }
const typeValidationErrors = this.validateNavigationMenuItemType({ validationResult.errors.push(
type: flatNavigationMenuItem.type, ...validateNavigationMenuItemTypeRequiredProperties({
hasTargetRecordId: isDefined(flatNavigationMenuItem.targetRecordId), flatNavigationMenuItem,
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);
if (isDefined(flatNavigationMenuItem.folderUniversalIdentifier)) { if (isDefined(flatNavigationMenuItem.folderUniversalIdentifier)) {
const circularDependencyErrors = const circularDependencyErrors =
@@ -257,6 +156,8 @@ export class FlatNavigationMenuItemValidatorService {
const validationResult = getEmptyFlatEntityValidationError({ const validationResult = getEmptyFlatEntityValidationError({
flatEntityMinimalInformation: { flatEntityMinimalInformation: {
universalIdentifier: flatEntityToValidate.universalIdentifier, universalIdentifier: flatEntityToValidate.universalIdentifier,
name: flatEntityToValidate.name,
type: flatEntityToValidate.type,
}, },
metadataName: 'navigationMenuItem', metadataName: 'navigationMenuItem',
type: 'delete', type: 'delete',
@@ -295,6 +196,8 @@ export class FlatNavigationMenuItemValidatorService {
const validationResult = getEmptyFlatEntityValidationError({ const validationResult = getEmptyFlatEntityValidationError({
flatEntityMinimalInformation: { flatEntityMinimalInformation: {
universalIdentifier, universalIdentifier,
name: flatEntityUpdate.name ?? fromFlatNavigationMenuItem?.name,
type: flatEntityUpdate.type ?? fromFlatNavigationMenuItem?.type,
}, },
metadataName: 'navigationMenuItem', metadataName: 'navigationMenuItem',
type: 'update', type: 'update',
@@ -325,23 +228,11 @@ export class FlatNavigationMenuItemValidatorService {
...flatEntityUpdate, ...flatEntityUpdate,
}; };
const nameUpdate = flatEntityUpdate.name; validationResult.errors.push(
...validateNavigationMenuItemTypeRequiredProperties({
const typeValidationErrors = this.validateNavigationMenuItemType({ flatNavigationMenuItem: toFlatNavigationMenuItem,
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);
const folderUniversalIdentifierUpdate = const folderUniversalIdentifierUpdate =
flatEntityUpdate.folderUniversalIdentifier; flatEntityUpdate.folderUniversalIdentifier;
@@ -0,0 +1,334 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sync application should fail on invalid navigation menu items when syncing a FOLDER item with a blank name 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A name is required for FOLDER type",
"userFriendlyMessage": "A name is required for FOLDER type",
},
],
"flatEntityMinimalInformation": {
"name": " ",
"type": "FOLDER",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A name is required for FOLDER type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing a FOLDER item without name 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A name is required for FOLDER type",
"userFriendlyMessage": "A name is required for FOLDER type",
},
],
"flatEntityMinimalInformation": {
"name": null,
"type": "FOLDER",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A name is required for FOLDER type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing a LINK item with a link that is not a valid url 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A valid link is required for LINK type",
"userFriendlyMessage": "A valid link is required for LINK type",
},
],
"flatEntityMinimalInformation": {
"name": "Link with invalid url",
"type": "LINK",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A valid link is required for LINK type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing a LINK item without link 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A valid link is required for LINK type",
"userFriendlyMessage": "A valid link is required for LINK type",
},
],
"flatEntityMinimalInformation": {
"name": "Link without url",
"type": "LINK",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A valid link is required for LINK type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing a PAGE_LAYOUT item with a pageLayoutUniversalIdentifier that is not a uuid 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type",
"userFriendlyMessage": "A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type",
},
],
"flatEntityMinimalInformation": {
"name": "Page layout with malformed identifier",
"type": "PAGE_LAYOUT",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing a VIEW item with a viewUniversalIdentifier that is not a uuid 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A valid viewUniversalIdentifier is required for VIEW type",
"userFriendlyMessage": "A valid viewUniversalIdentifier is required for VIEW type",
},
],
"flatEntityMinimalInformation": {
"name": "View with malformed identifier",
"type": "VIEW",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A valid viewUniversalIdentifier is required for VIEW type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing an OBJECT item with a targetObjectUniversalIdentifier that is not a uuid 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
"userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
},
],
"flatEntityMinimalInformation": {
"name": "Object with malformed target",
"type": "OBJECT",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing an OBJECT item without targetObjectUniversalIdentifier 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
"userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
},
],
"flatEntityMinimalInformation": {
"name": "Object without target",
"type": "OBJECT",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing an item with an unknown type 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "Unknown navigation menu item type UNKNOWN_NAVIGATION_MENU_ITEM_TYPE",
"userFriendlyMessage": "Unknown navigation menu item type UNKNOWN_NAVIGATION_MENU_ITEM_TYPE",
},
],
"flatEntityMinimalInformation": {
"name": "Item with unknown type",
"type": "UNKNOWN_NAVIGATION_MENU_ITEM_TYPE",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Unknown navigation menu item type UNKNOWN_NAVIGATION_MENU_ITEM_TYPE",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
@@ -0,0 +1,166 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { type NavigationMenuItemManifest } from 'twenty-shared/application';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { NavigationMenuItemType } from 'twenty-shared/types';
const TEST_APP_ID = 'c1b2c3d4-0001-4000-a000-000000000001';
const TEST_ROLE_ID = 'c1b2c3d4-0002-4000-a000-000000000002';
const TEST_NAVIGATION_MENU_ITEM_ID = 'c1b2c3d4-0003-4000-a000-000000000003';
type TestContext = {
navigationMenuItem: NavigationMenuItemManifest;
};
const failingNavigationMenuItemSyncTestCases: EachTestingContext<TestContext>[] =
[
{
title: 'when syncing a FOLDER item without name',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.FOLDER,
position: 0,
icon: 'IconFolder',
},
},
},
{
title: 'when syncing a FOLDER item with a blank name',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.FOLDER,
position: 0,
name: ' ',
},
},
},
{
title: 'when syncing a LINK item without link',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.LINK,
position: 0,
name: 'Link without url',
},
},
},
{
title: 'when syncing a LINK item with a link that is not a valid url',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.LINK,
position: 0,
name: 'Link with invalid url',
link: 'not a link',
},
},
},
{
title:
'when syncing an OBJECT item without targetObjectUniversalIdentifier',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.OBJECT,
position: 0,
name: 'Object without target',
},
},
},
{
title:
'when syncing an OBJECT item with a targetObjectUniversalIdentifier that is not a uuid',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.OBJECT,
position: 0,
name: 'Object with malformed target',
targetObjectUniversalIdentifier: 'not-a-uuid',
},
},
},
{
title:
'when syncing a VIEW item with a viewUniversalIdentifier that is not a uuid',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.VIEW,
position: 0,
name: 'View with malformed identifier',
viewUniversalIdentifier: 'not-a-uuid',
},
},
},
{
title:
'when syncing a PAGE_LAYOUT item with a pageLayoutUniversalIdentifier that is not a uuid',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.PAGE_LAYOUT,
position: 0,
name: 'Page layout with malformed identifier',
pageLayoutUniversalIdentifier: 'not-a-uuid',
},
},
},
{
title: 'when syncing an item with an unknown type',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: 'UNKNOWN_NAVIGATION_MENU_ITEM_TYPE' as NavigationMenuItemType,
position: 0,
name: 'Item with unknown type',
},
},
},
];
describe('Sync application should fail on invalid navigation menu items', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Invalid Navigation Menu Item App',
description: 'App for testing navigation menu item manifest validation',
sourcePath: 'test-invalid-navigation-menu-item',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it.each(eachTestingContextFilter(failingNavigationMenuItemSyncTestCases))(
'$title',
async ({ context }) => {
const { errors } = await syncApplication({
manifest: buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
navigationMenuItems: [context.navigationMenuItem],
},
}),
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
},
60000,
);
});
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NavigationMenuItem creation should fail when creating with empty targetObjectMetadataId 1`] = ` exports[`NavigationMenuItem creation should fail when creating with empty targetObjectMetadataId 1`] = `
{ {
@@ -100,11 +100,13 @@ exports[`NavigationMenuItem creation should fail when creating with missing targ
"errors": [ "errors": [
{ {
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "targetRecordId and targetObjectMetadataId are required for RECORD type", "message": "A valid targetObjectMetadataUniversalIdentifier is required for RECORD type",
"userFriendlyMessage": "targetRecordId and targetObjectMetadataId are required for RECORD type", "userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for RECORD type",
}, },
], ],
"flatEntityMinimalInformation": { "flatEntityMinimalInformation": {
"name": null,
"type": "RECORD",
"universalIdentifier": Any<String>, "universalIdentifier": Any<String>,
}, },
"metadataName": "navigationMenuItem", "metadataName": "navigationMenuItem",
@@ -118,7 +120,7 @@ exports[`NavigationMenuItem creation should fail when creating with missing targ
"navigationMenuItem": 1, "navigationMenuItem": 1,
"totalErrors": 1, "totalErrors": 1,
}, },
"userFriendlyMessage": "targetRecordId and targetObjectMetadataId are required for RECORD type", "userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for RECORD type",
}, },
"message": "Multiple validation errors occurred while creating navigation menu items", "message": "Multiple validation errors occurred while creating navigation menu items",
"name": "GraphQLError", "name": "GraphQLError",
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Navigation Menu Item update should fail with circular dependency when folderId equals id (self-reference) 1`] = ` exports[`Navigation Menu Item update should fail with circular dependency when folderId equals id (self-reference) 1`] = `
{ {
@@ -15,6 +15,8 @@ exports[`Navigation Menu Item update should fail with circular dependency when f
}, },
], ],
"flatEntityMinimalInformation": { "flatEntityMinimalInformation": {
"name": "Standalone Folder",
"type": "FOLDER",
"universalIdentifier": Any<String>, "universalIdentifier": Any<String>,
}, },
"metadataName": "navigationMenuItem", "metadataName": "navigationMenuItem",
@@ -50,6 +52,8 @@ exports[`Navigation Menu Item update should fail with circular dependency when u
}, },
], ],
"flatEntityMinimalInformation": { "flatEntityMinimalInformation": {
"name": "Parent Folder",
"type": "FOLDER",
"universalIdentifier": Any<String>, "universalIdentifier": Any<String>,
}, },
"metadataName": "navigationMenuItem", "metadataName": "navigationMenuItem",
@@ -0,0 +1,3 @@
// Type level counterpart of assertUnreachable: fails to compile when a switch
// statement does not handle every case, without any runtime behavior
export type AssertUnreachable<T extends never> = T;
@@ -15,6 +15,7 @@ export { AppBasePath } from './AppBasePath';
export { AppPath } from './AppPath'; export { AppPath } from './AppPath';
export type { Arrayable } from './Arrayable'; export type { Arrayable } from './Arrayable';
export type { ArraySortDirection } from './ArraySortDirection'; export type { ArraySortDirection } from './ArraySortDirection';
export type { AssertUnreachable } from './AssertUnreachable.type';
export { CalendarChannelContactAutoCreationPolicy } from './CalendarChannelContactAutoCreationPolicy'; export { CalendarChannelContactAutoCreationPolicy } from './CalendarChannelContactAutoCreationPolicy';
export { CalendarChannelSyncStage } from './CalendarChannelSyncStage'; export { CalendarChannelSyncStage } from './CalendarChannelSyncStage';
export { CalendarChannelSyncStatus } from './CalendarChannelSyncStatus'; export { CalendarChannelSyncStatus } from './CalendarChannelSyncStatus';