Files
twenty/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-refactor-navigation-commands.command.ts
T
Raphaël Bosi d341d0d624 Refactor navigation commands to use NAVIGATION engine key with payload (#19303)
- Adds a payload JSON column to `CommandMenuItem` and introduces a
unified `NAVIGATION` engine component key that replaces all individual
GO_TO_* keys
- Navigation commands now use the payload to determine their target
(either an objectMetadataItemId or a path), making navigation commands
dynamic and eliminating the need for a hardcoded engine key per object
- Includes a 1.21 upgrade command (refactor-navigation-commands) that
migrates existing GO_TO_* items to NAVIGATION items with the appropriate
payload, and applies a CHECK constraint enforcing payload coherence



https://github.com/user-attachments/assets/4d305ba2-ae0b-4556-bb0e-e9d899777350


TODO: In a second PR, create the sync between object metadata items and
the navigation command menu items
- Object metadata item created or enabled -> Create navigation command
- Object metadata item deleted or disabled -> Delete associated
navigation command

In another PR:
- Allow `label`, `shortLabel` and `icon` to resolve the
`navigateToObjectMetadataItem` dynamically in their interpolation
instead of being hardcoded in the command menu item
- Make the icon dynamic in the command menu items as the label so that
we can resolve ${navigateToObjectMetadataItem.icon} at runTime -> This
way we won't need to keep update the command menu item icon when we
update the objectMetadataItem icon
2026-04-07 15:00:04 +00:00

265 lines
9.2 KiB
TypeScript

import { InjectDataSource } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { DataSource } from 'typeorm';
import { v4, v5 } from 'uuid';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import {
type RunOnWorkspaceArgs,
type WorkspaceCommandOptions,
} from 'src/database/commands/command-runners/workspace.command-runner';
import { addPayloadCheckConstraintToCommandMenuItem } from 'src/database/typeorm/core/migrations/utils/1775129635528-add-payload-to-command-menu-item.util';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
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 { seedCompareObjectMetadataForNavigationPosition } from 'src/engine/metadata-modules/flat-command-menu-item/utils/seed-compare-object-metadata-for-navigation-position.util';
import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const GO_TO_ENGINE_KEYS = [
'GO_TO_PEOPLE',
'GO_TO_COMPANIES',
'GO_TO_DASHBOARDS',
'GO_TO_OPPORTUNITIES',
'GO_TO_SETTINGS',
'GO_TO_TASKS',
'GO_TO_NOTES',
'GO_TO_WORKFLOWS',
'GO_TO_RUNS',
];
@Command({
name: 'upgrade:1-21:refactor-navigation-commands',
description:
'Replace GO_TO_* command menu items with unified NAVIGATION engine key and payload',
})
export class RefactorNavigationCommandsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async run(
passedParams: string[],
options: WorkspaceCommandOptions,
): Promise<void> {
await super.run(passedParams, options);
if (options.workspaceId && options.workspaceId.size > 0) {
this.logger.log(
'Skipping CHECK constraint application: command was not launched for all workspaces',
);
return;
}
if (options.dryRun) {
this.logger.log(
'[DRY RUN] Would apply CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE',
);
return;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
try {
await addPayloadCheckConstraintToCommandMenuItem(queryRunner);
this.logger.log(
'Successfully applied CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE',
);
} finally {
await queryRunner.release();
}
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Refactoring navigation commands for workspace ${workspaceId}`,
);
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { flatCommandMenuItemMaps, flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatCommandMenuItemMaps',
'flatObjectMetadataMaps',
]);
const allCommandMenuItems = Object.values(
flatCommandMenuItemMaps.byUniversalIdentifier,
).filter(isDefined);
const standardAppCommandMenuItems = allCommandMenuItems.filter(
(item) => item.applicationId === twentyStandardFlatApplication.id,
);
const goToItemsToDelete = standardAppCommandMenuItems.filter((item) =>
GO_TO_ENGINE_KEYS.includes(item.engineComponentKey),
);
this.logger.log(
`${isDryRun ? '[DRY RUN] Would delete' : 'Deleting'} ${goToItemsToDelete.length} old GO_TO_* command(s) for workspace ${workspaceId}`,
);
const existingNavigationUniversalIdentifiers = new Set(
allCommandMenuItems
.filter(
(item) => item.engineComponentKey === EngineComponentKey.NAVIGATION,
)
.map((item) => item.universalIdentifier),
);
const activeObjects = Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((objectMetadata) => objectMetadata.isActive)
.sort(seedCompareObjectMetadataForNavigationPosition);
this.logger.log(
`Found ${activeObjects.length} active object(s) for workspace ${workspaceId}`,
);
const nonGoToItems = allCommandMenuItems.filter(
(item) => !GO_TO_ENGINE_KEYS.includes(item.engineComponentKey),
);
let nextPosition =
nonGoToItems.reduce((max, item) => Math.max(max, item.position), -1) + 1;
const now = new Date().toISOString();
const flatCommandMenuItemsToCreate: FlatCommandMenuItem[] = [];
for (const objectMetadata of activeObjects) {
const universalIdentifier = v5(
objectMetadata.universalIdentifier,
NAVIGATION_COMMAND_UUID_NAMESPACE,
);
if (existingNavigationUniversalIdentifiers.has(universalIdentifier)) {
continue;
}
flatCommandMenuItemsToCreate.push(
buildNavigationFlatCommandMenuItem({
objectMetadata,
commandMenuItemId: v4(),
applicationId: twentyStandardFlatApplication.id,
workspaceId,
position: nextPosition++,
now,
}),
);
}
const settingsUniversalIdentifier =
STANDARD_COMMAND_MENU_ITEMS.goToSettings.universalIdentifier;
if (
!existingNavigationUniversalIdentifiers.has(settingsUniversalIdentifier)
) {
flatCommandMenuItemsToCreate.push({
id: v4(),
universalIdentifier: settingsUniversalIdentifier,
applicationId: twentyStandardFlatApplication.id,
applicationUniversalIdentifier:
TWENTY_STANDARD_APPLICATION.universalIdentifier,
workspaceId,
label: 'Go to Settings',
shortLabel: 'Settings',
icon: 'IconSettings',
position: nextPosition++,
isPinned: false,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: null,
frontComponentId: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.NAVIGATION,
payload: { path: '/settings/profile' },
hotKeys: ['G', 'S'],
workflowVersionId: null,
availabilityObjectMetadataId: null,
availabilityObjectMetadataUniversalIdentifier: null,
createdAt: now,
updatedAt: now,
});
}
if (
goToItemsToDelete.length === 0 &&
flatCommandMenuItemsToCreate.length === 0
) {
this.logger.log(
`All NAVIGATION commands already exist for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] Would create' : 'Creating'} ${flatCommandMenuItemsToCreate.length} NAVIGATION command(s) for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: flatCommandMenuItemsToCreate,
flatEntityToDelete: goToItemsToDelete,
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to refactor navigation commands:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to refactor navigation commands for workspace ${workspaceId}`,
);
}
this.logger.log(
`Successfully refactored navigation commands for workspace ${workspaceId}`,
);
}
}