[COMMAND MENU ITEMS] Sync object metadata with navigation command menu items (#19456)

When an object is created or enabled we create a navigation command menu
item to that object. And when the object is disabled or deleted, we
remove this command menu item.
This commit is contained in:
Raphaël Bosi
2026-04-09 10:18:49 +02:00
committed by GitHub
parent f8dff23a3e
commit 9080180156
8 changed files with 630 additions and 35 deletions
@@ -1,5 +1,6 @@
import {
CreateOneObjectMetadataItemDocument,
FindManyCommandMenuItemsDocument,
FindManyNavigationMenuItemsDocument,
FindManyViewsDocument,
} from '~/generated-metadata/graphql';
@@ -11,6 +12,8 @@ export const findManyViewsQuery = FindManyViewsDocument;
export const findManyNavigationMenuItemsQuery =
FindManyNavigationMenuItemsDocument;
export const findManyCommandMenuItemsQuery = FindManyCommandMenuItemsDocument;
export const variables = {
input: {
object: {
@@ -4,6 +4,7 @@ import { useCreateOneObjectMetadataItem } from '@/object-metadata/hooks/useCreat
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import {
findManyCommandMenuItemsQuery,
findManyNavigationMenuItemsQuery,
findManyViewsQuery,
query,
@@ -12,7 +13,7 @@ import {
} from '@/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem';
import { jestExpectSuccessfulMetadataRequestResult } from '@/object-metadata/hooks/__tests__/utils/jest-expect-metadata-request-status.util';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { GetCurrentUserDocument } from '~/generated-metadata/graphql';
import { mockedUserData } from '~/testing/mock-data/users';
import {
query as findManyObjectMetadataItemsQuery,
@@ -33,7 +34,7 @@ const mocks = [
},
{
request: {
query: GET_CURRENT_USER,
query: GetCurrentUserDocument,
variables: {},
},
result: jest.fn(() => ({
@@ -75,6 +76,17 @@ const mocks = [
},
})),
},
{
request: {
query: findManyCommandMenuItemsQuery,
variables: {},
},
result: jest.fn(() => ({
data: {
commandMenuItems: [],
},
})),
},
];
const Wrapper = getJestMetadataAndApolloMocksWrapper({
@@ -11,6 +11,7 @@ import {
import { jestExpectSuccessfulMetadataRequestResult } from '@/object-metadata/hooks/__tests__/utils/jest-expect-metadata-request-status.util';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { FIND_ALL_VIEWS } from '@/views/graphql/queries/findAllViews';
import { FindManyCommandMenuItemsDocument } from '~/generated-metadata/graphql';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { mockedUserData } from '~/testing/mock-data/users';
import { mockedViews } from '~/testing/mock-data/generated/metadata/views/mock-views-data';
@@ -62,6 +63,17 @@ const mocks = [
data: findManyObjectMetadataItemsResponseData,
})),
},
{
request: {
query: FindManyCommandMenuItemsDocument,
variables: {},
},
result: jest.fn(() => ({
data: {
commandMenuItems: [],
},
})),
},
];
const Wrapper = getJestMetadataAndApolloMocksWrapper({
@@ -2,6 +2,7 @@ import { useApolloClient, useMutation } from '@apollo/client/react';
import {
type CreateObjectInput,
CreateOneObjectMetadataItemDocument,
FindManyCommandMenuItemsDocument,
FindManyNavigationMenuItemsDocument,
FindManyViewsDocument,
} from '~/generated-metadata/graphql';
@@ -13,6 +14,7 @@ import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMe
import { splitViewWithRelated } from '@/metadata-store/utils/splitViewWithRelated';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { t } from '@lingui/core/macro';
import { CrudOperationType } from 'twenty-shared/types';
@@ -28,6 +30,7 @@ export const useCreateOneObjectMetadataItem = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const { addToDraft, replaceDraft, applyChanges } =
useUpdateMetadataStoreDraft();
const { loadCurrentUser } = useLoadCurrentUser();
const createOneObjectMetadataItem = async (
input: CreateObjectInput,
@@ -70,17 +73,22 @@ export const useCreateOneObjectMetadataItem = () => {
applyChanges();
const [viewsResult, navItemsResult] = await Promise.all([
client.query({
query: FindManyViewsDocument,
variables: { objectMetadataId: createdObject.id },
fetchPolicy: 'network-only',
}),
client.query({
query: FindManyNavigationMenuItemsDocument,
fetchPolicy: 'network-only',
}),
]);
const [viewsResult, navItemsResult, commandMenuItemsResult] =
await Promise.all([
client.query({
query: FindManyViewsDocument,
variables: { objectMetadataId: createdObject.id },
fetchPolicy: 'network-only',
}),
client.query({
query: FindManyNavigationMenuItemsDocument,
fetchPolicy: 'network-only',
}),
client.query({
query: FindManyCommandMenuItemsDocument,
fetchPolicy: 'network-only',
}),
]);
const fetchedViews = viewsResult.data?.getViews ?? [];
@@ -107,7 +115,14 @@ export const useCreateOneObjectMetadataItem = () => {
navItemsResult.data?.navigationMenuItems ?? [],
);
replaceDraft(
'commandMenuItems',
commandMenuItemsResult.data?.commandMenuItems ?? [],
);
applyChanges();
await loadCurrentUser();
}
return {
@@ -1,5 +1,8 @@
import { useMutation } from '@apollo/client/react';
import { DeleteOneObjectMetadataItemDocument } from '~/generated-metadata/graphql';
import { useApolloClient, useMutation } from '@apollo/client/react';
import {
DeleteOneObjectMetadataItemDocument,
FindManyCommandMenuItemsDocument,
} from '~/generated-metadata/graphql';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
@@ -14,9 +17,11 @@ export const useDeleteOneObjectMetadataItem = () => {
DeleteOneObjectMetadataItemDocument,
);
const client = useApolloClient();
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const { removeFromDraft, applyChanges } = useUpdateMetadataStoreDraft();
const { removeFromDraft, replaceDraft, applyChanges } =
useUpdateMetadataStoreDraft();
const deleteOneObjectMetadataItem = async (
idToDelete: string,
@@ -35,6 +40,17 @@ export const useDeleteOneObjectMetadataItem = () => {
removeFromDraft({ key: 'objectMetadataItems', itemIds: [idToDelete] });
applyChanges();
const commandMenuItemsResult = await client.query({
query: FindManyCommandMenuItemsDocument,
fetchPolicy: 'network-only',
});
replaceDraft(
'commandMenuItems',
commandMenuItemsResult.data?.commandMenuItems ?? [],
);
applyChanges();
return {
status: 'successful',
response,
@@ -1,6 +1,7 @@
import { useMutation } from '@apollo/client/react';
import { useApolloClient, useMutation } from '@apollo/client/react';
import {
type UpdateOneObjectInput,
FindManyCommandMenuItemsDocument,
UpdateOneObjectMetadataItemDocument,
} from '~/generated-metadata/graphql';
@@ -20,9 +21,11 @@ export const useUpdateOneObjectMetadataItem = () => {
UpdateOneObjectMetadataItemDocument,
);
const client = useApolloClient();
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const { updateInDraft, applyChanges } = useUpdateMetadataStoreDraft();
const { updateInDraft, replaceDraft, applyChanges } =
useUpdateMetadataStoreDraft();
const updateOneObjectMetadataItem = async ({
idToUpdate,
@@ -52,6 +55,17 @@ export const useUpdateOneObjectMetadataItem = () => {
objectData as FlatObjectMetadataItem,
]);
applyChanges();
const commandMenuItemsResult = await client.query({
query: FindManyCommandMenuItemsDocument,
fetchPolicy: 'network-only',
});
replaceDraft(
'commandMenuItems',
commandMenuItemsResult.data?.commandMenuItems ?? [],
);
applyChanges();
}
return {
@@ -11,12 +11,15 @@ import {
} from 'twenty-shared/types';
import { fromArrayToUniqueKeyRecord, isDefined } from 'twenty-shared/utils';
import { FindManyOptions, FindOneOptions, Repository } from 'typeorm';
import { v4 as uuidv4, v4 } from 'uuid';
import { v4, v5 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import {
buildNavigationFlatCommandMenuItem,
NAVIGATION_COMMAND_UUID_NAMESPACE,
} from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-navigation-flat-command-menu-item.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
@@ -43,7 +46,6 @@ import {
import { computeFlatDefaultRecordPageLayoutToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-default-record-page-layout-to-create.util';
import { computeFlatRecordPageFieldsViewToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-record-page-fields-view-to-create.util';
import { computeFlatViewFieldsToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-view-fields-to-create.util';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@@ -59,10 +61,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly applicationService: ApplicationService,
private readonly featureFlagService: FeatureFlagService,
private readonly twentyConfigService: TwentyConfigService,
) {
super(objectMetadataRepository);
}
@@ -76,13 +75,13 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
updateObjectInput: UpdateOneObjectInput;
ownerFlatApplication?: FlatApplication;
}): Promise<FlatObjectMetadata> {
const { workspaceCustomFlatApplication, twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const resolvedOwnerFlatApplication =
ownerFlatApplication ??
(
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
)
).workspaceCustomFlatApplication;
ownerFlatApplication ?? workspaceCustomFlatApplication;
const {
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
@@ -90,6 +89,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatFieldMetadataMaps: existingFlatFieldMetadataMaps,
flatViewFieldMaps: existingFlatViewFieldMaps,
flatViewMaps: existingFlatViewMaps,
flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps,
} = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
@@ -99,6 +99,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
'flatFieldMetadataMaps',
'flatViewFieldMaps',
'flatViewMaps',
'flatCommandMenuItemMaps',
],
},
);
@@ -119,6 +120,35 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatViewMaps: existingFlatViewMaps,
});
const existingFlatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: existingFlatObjectMetadataMaps,
flatEntityId: updateObjectInput.id,
});
const isActiveChangeDefined = isDefined(updateObjectInput.update.isActive);
const isBeingEnabled =
isActiveChangeDefined &&
updateObjectInput.update.isActive === true &&
isDefined(existingFlatObjectMetadata) &&
!existingFlatObjectMetadata.isActive;
const isBeingDisabled =
isActiveChangeDefined &&
updateObjectInput.update.isActive === false &&
isDefined(existingFlatObjectMetadata) &&
existingFlatObjectMetadata.isActive;
const { commandMenuItemsToCreate, commandMenuItemsToDelete } =
this.computeCommandMenuItemChangesForActiveToggle({
isBeingEnabled,
isBeingDisabled,
existingFlatObjectMetadata,
flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps,
workspaceId,
applicationId: twentyStandardFlatApplication.id,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@@ -161,6 +191,35 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
);
}
const hasCommandMenuItemChanges =
commandMenuItemsToCreate.length > 0 ||
commandMenuItemsToDelete.length > 0;
if (hasCommandMenuItemChanges) {
const commandMenuItemMigrationResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: commandMenuItemsToCreate,
flatEntityToDelete: commandMenuItemsToDelete,
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (commandMenuItemMigrationResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
commandMenuItemMigrationResult,
'Multiple validation errors occurred while updating command menu items',
);
}
}
const { flatObjectMetadataMaps: recomputedFlatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -187,10 +246,10 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
]);
}
if (isDefined(updateObjectInput.update.isActive)) {
if (isActiveChangeDefined) {
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: ['flatNavigationMenuItemMaps'],
flatMapsKeys: ['flatNavigationMenuItemMaps', 'flatCommandMenuItemMaps'],
});
}
@@ -250,7 +309,12 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
)
).workspaceCustomFlatApplication;
const { flatObjectMetadataMaps, flatFieldMetadataMaps, flatIndexMaps } =
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatIndexMaps,
flatCommandMenuItemMaps,
} =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
@@ -258,6 +322,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
'flatObjectMetadataMaps',
'flatIndexMaps',
'flatFieldMetadataMaps',
'flatCommandMenuItemMaps',
],
},
);
@@ -339,6 +404,16 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
}),
);
const flatCommandMenuItemsToDelete = flatObjectMetadatasToDelete
.map((flatObjectMetadataToDelete) =>
this.findNavigationCommandMenuItemForObject({
objectUniversalIdentifier:
flatObjectMetadataToDelete.universalIdentifier,
flatCommandMenuItemMaps,
}),
)
.filter(isDefined);
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@@ -358,6 +433,11 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatEntityToDelete: flatFieldMetadatasToDelete,
flatEntityToUpdate: [],
},
commandMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: flatCommandMenuItemsToDelete,
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild,
@@ -441,6 +521,23 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
workspaceCustomFlatApplication.universalIdentifier,
});
const { flatCommandMenuItemMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatCommandMenuItemMaps'],
},
);
const flatCommandMenuItemToCreate = this.buildFlatNavigationCommandMenuItem(
{
objectMetadata: flatObjectMetadataToCreate,
workspaceId,
applicationId: twentyStandardFlatApplication.id,
flatCommandMenuItemMaps,
},
);
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@@ -497,6 +594,29 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
);
}
const commandMenuItemMigrationResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: [flatCommandMenuItemToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (commandMenuItemMigrationResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
commandMenuItemMigrationResult,
'Multiple validation errors occurred while creating command menu item',
);
}
if (isRecordPageLayoutEditingEnabled) {
const flatRecordPageFieldsViewToCreate =
this.computeFlatRecordPageFieldsViewToCreate({
@@ -699,7 +819,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
? Math.max(...workspaceLevelItems.map((item) => item.position)) + 1
: 0;
const newId = uuidv4();
const newId = v4();
const now = new Date().toISOString();
return {
@@ -729,6 +849,131 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
};
}
private buildFlatNavigationCommandMenuItem({
objectMetadata,
workspaceId,
applicationId,
flatCommandMenuItemMaps,
}: {
objectMetadata: {
id: string;
universalIdentifier: string;
labelPlural: string;
icon: string | null;
nameSingular: string;
shortcut: string | null;
};
workspaceId: string;
applicationId: string;
flatCommandMenuItemMaps: {
byUniversalIdentifier: Record<string, FlatCommandMenuItem | undefined>;
};
}): FlatCommandMenuItem {
const existingItems = Object.values(
flatCommandMenuItemMaps.byUniversalIdentifier,
).filter(isDefined);
const nextPosition =
existingItems.length > 0
? Math.max(...existingItems.map((item) => item.position)) + 1
: 0;
return buildNavigationFlatCommandMenuItem({
objectMetadata,
commandMenuItemId: v4(),
applicationId,
workspaceId,
position: nextPosition,
now: new Date().toISOString(),
});
}
private findNavigationCommandMenuItemForObject({
objectUniversalIdentifier,
flatCommandMenuItemMaps,
}: {
objectUniversalIdentifier: string;
flatCommandMenuItemMaps: {
byUniversalIdentifier: Record<string, FlatCommandMenuItem | undefined>;
};
}): FlatCommandMenuItem | undefined {
const commandMenuItemUniversalIdentifier = v5(
objectUniversalIdentifier,
NAVIGATION_COMMAND_UUID_NAMESPACE,
);
return findFlatEntityByUniversalIdentifier({
flatEntityMaps: flatCommandMenuItemMaps,
universalIdentifier: commandMenuItemUniversalIdentifier,
});
}
private computeCommandMenuItemChangesForActiveToggle({
isBeingEnabled,
isBeingDisabled,
existingFlatObjectMetadata,
flatCommandMenuItemMaps,
workspaceId,
applicationId,
}: {
isBeingEnabled: boolean;
isBeingDisabled: boolean;
existingFlatObjectMetadata: FlatObjectMetadata | undefined;
flatCommandMenuItemMaps: {
byUniversalIdentifier: Record<string, FlatCommandMenuItem | undefined>;
};
workspaceId: string;
applicationId: string;
}): {
commandMenuItemsToCreate: FlatCommandMenuItem[];
commandMenuItemsToDelete: FlatCommandMenuItem[];
} {
if (!isDefined(existingFlatObjectMetadata)) {
return { commandMenuItemsToCreate: [], commandMenuItemsToDelete: [] };
}
if (isBeingEnabled) {
const existingCommandMenuItem =
this.findNavigationCommandMenuItemForObject({
objectUniversalIdentifier:
existingFlatObjectMetadata.universalIdentifier,
flatCommandMenuItemMaps,
});
if (!isDefined(existingCommandMenuItem)) {
return {
commandMenuItemsToCreate: [
this.buildFlatNavigationCommandMenuItem({
objectMetadata: existingFlatObjectMetadata,
workspaceId,
applicationId,
flatCommandMenuItemMaps,
}),
],
commandMenuItemsToDelete: [],
};
}
}
if (isBeingDisabled) {
const commandMenuItemToDelete =
this.findNavigationCommandMenuItemForObject({
objectUniversalIdentifier:
existingFlatObjectMetadata.universalIdentifier,
flatCommandMenuItemMaps,
});
if (isDefined(commandMenuItemToDelete)) {
return {
commandMenuItemsToCreate: [],
commandMenuItemsToDelete: [commandMenuItemToDelete],
};
}
}
return { commandMenuItemsToCreate: [], commandMenuItemsToDelete: [] };
}
public async findOneWithinWorkspace(
workspaceId: string,
options: FindOneOptions<ObjectMetadataEntity>,
@@ -0,0 +1,278 @@
import { findCommandMenuItems } from 'test/integration/metadata/suites/command-menu-item/utils/find-command-menu-items.util';
import { type CreateOneObjectFactoryInput } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata-query-factory.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { isDefined } from 'twenty-shared/utils';
import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto';
import { type ObjectMetadataCommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/dtos/types/object-metadata-command-menu-item-payload.type';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
const findNavigationCommandMenuItemForObject = (
commandMenuItems: CommandMenuItemDTO[],
objectMetadataId: string,
) =>
commandMenuItems.find(
(item) =>
item.engineComponentKey === EngineComponentKey.NAVIGATION &&
(item.payload as ObjectMetadataCommandMenuItemPayload | undefined)
?.objectMetadataItemId === objectMetadataId,
);
const COMMAND_MENU_ITEM_GQL_FIELDS = `
id
engineComponentKey
label
icon
payload {
... on PathCommandMenuItemPayload {
path
}
... on ObjectMetadataCommandMenuItemPayload {
objectMetadataItemId
}
}
`;
describe('Command menu item side effect on object metadata', () => {
let createdObjectMetadataId: string | undefined = undefined;
const uniqueSuffix = Date.now().toString().slice(-8);
const createObjectInput: CreateOneObjectFactoryInput = {
namePlural: `sideEffectItems${uniqueSuffix}`,
nameSingular: `sideEffectItem${uniqueSuffix}`,
labelPlural: `Side Effect Items ${uniqueSuffix}`,
labelSingular: `Side Effect Item ${uniqueSuffix}`,
description: 'Object for command menu item side effect tests',
icon: 'IconBox',
isLabelSyncedWithName: false,
};
afterEach(async () => {
if (!isDefined(createdObjectMetadataId)) {
return;
}
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
expectToFail: false,
});
createdObjectMetadataId = undefined;
});
it('should create a navigation command menu item when a custom object is created', async () => {
const {
data: { createOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: createObjectInput,
gqlFields: 'id',
});
createdObjectMetadataId = createOneObject.id;
const {
data: { commandMenuItems },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
const navigationItem = findNavigationCommandMenuItemForObject(
commandMenuItems,
createdObjectMetadataId,
);
expect(navigationItem).toEqual(
expect.objectContaining({
label: `Go to ${createObjectInput.labelPlural}`,
icon: createObjectInput.icon,
engineComponentKey: EngineComponentKey.NAVIGATION,
}),
);
});
it('should delete the navigation command menu item when a custom object is deleted', async () => {
const {
data: { createOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: createObjectInput,
gqlFields: 'id',
});
createdObjectMetadataId = createOneObject.id;
const deletedObjectId = createdObjectMetadataId;
const {
data: { commandMenuItems: itemsBeforeDelete },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
expect(
findNavigationCommandMenuItemForObject(
itemsBeforeDelete,
deletedObjectId,
),
).toBeDefined();
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
expectToFail: false,
});
createdObjectMetadataId = undefined;
const {
data: { commandMenuItems: itemsAfterDelete },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
expect(
findNavigationCommandMenuItemForObject(
itemsAfterDelete,
deletedObjectId,
),
).toBeUndefined();
});
it('should delete the navigation command menu item when a custom object is disabled', async () => {
const {
data: { createOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: createObjectInput,
gqlFields: 'id',
});
createdObjectMetadataId = createOneObject.id;
const {
data: { commandMenuItems: itemsBeforeDisable },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
expect(
findNavigationCommandMenuItemForObject(
itemsBeforeDisable,
createdObjectMetadataId,
),
).toBeDefined();
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
const {
data: { commandMenuItems: itemsAfterDisable },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
expect(
findNavigationCommandMenuItemForObject(
itemsAfterDisable,
createdObjectMetadataId,
),
).toBeUndefined();
});
it('should recreate the navigation command menu item when a disabled object is re-enabled', async () => {
const {
data: { createOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: createObjectInput,
gqlFields: 'id',
});
createdObjectMetadataId = createOneObject.id;
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
const {
data: { commandMenuItems: itemsWhileDisabled },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
expect(
findNavigationCommandMenuItemForObject(
itemsWhileDisabled,
createdObjectMetadataId,
),
).toBeUndefined();
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: true },
},
});
const {
data: { commandMenuItems: itemsAfterReEnable },
} = await findCommandMenuItems({
expectToFail: false,
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
const navigationItem = findNavigationCommandMenuItemForObject(
itemsAfterReEnable,
createdObjectMetadataId,
);
expect(navigationItem).toEqual(
expect.objectContaining({
label: `Go to ${createObjectInput.labelPlural}`,
icon: createObjectInput.icon,
engineComponentKey: EngineComponentKey.NAVIGATION,
}),
);
});
});