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
This commit is contained in:
+1
@@ -270,6 +270,7 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaceC
|
||||
icon: trigger.settings.icon ?? null,
|
||||
isPinned: trigger.settings.isPinned ?? false,
|
||||
position: 0,
|
||||
payload: null,
|
||||
hotKeys: null,
|
||||
availabilityType,
|
||||
availabilityObjectMetadataId: availabilityObjectMetadataId ?? null,
|
||||
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { AddComposeEmailCommandMenuItemCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-compose-email-command-menu-item.command';
|
||||
import { MigrateMessagingInfrastructureToMetadataCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-migrate-messaging-infrastructure-to-metadata.command';
|
||||
import { RefactorNavigationCommandsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-refactor-navigation-commands.command';
|
||||
import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-add-global-key-value-pair-unique-index.command';
|
||||
import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-datasource-to-workspace.command';
|
||||
import { BackfillMessageThreadSubjectCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-message-thread-subject.command';
|
||||
@@ -59,6 +60,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
FixSelectAllCommandMenuItemsCommand,
|
||||
MigrateAiAgentTextToJsonResponseFormatCommand,
|
||||
UpdateEditLayoutCommandMenuItemLabelCommand,
|
||||
RefactorNavigationCommandsCommand,
|
||||
DropWorkspaceMessagingFksCommand,
|
||||
MigrateMessageFolderParentIdToExternalIdCommand,
|
||||
MigrateMessagingInfrastructureToMetadataCommand,
|
||||
@@ -73,6 +75,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
FixSelectAllCommandMenuItemsCommand,
|
||||
MigrateAiAgentTextToJsonResponseFormatCommand,
|
||||
UpdateEditLayoutCommandMenuItemLabelCommand,
|
||||
RefactorNavigationCommandsCommand,
|
||||
DropWorkspaceMessagingFksCommand,
|
||||
MigrateMessageFolderParentIdToExternalIdCommand,
|
||||
MigrateMessagingInfrastructureToMetadataCommand,
|
||||
|
||||
+4
@@ -24,7 +24,9 @@ import { MigrateMessagingInfrastructureToMetadataCommand } from 'src/database/co
|
||||
import { MigrateRichTextToTextCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-rich-text-to-text.command';
|
||||
import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command';
|
||||
import { UpdateStandardIndexViewNamesCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-update-standard-index-view-names.command';
|
||||
|
||||
import { AddComposeEmailCommandMenuItemCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-compose-email-command-menu-item.command';
|
||||
import { RefactorNavigationCommandsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-refactor-navigation-commands.command';
|
||||
import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-add-global-key-value-pair-unique-index.command';
|
||||
import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-datasource-to-workspace.command';
|
||||
import { BackfillMessageThreadSubjectCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-message-thread-subject.command';
|
||||
@@ -85,6 +87,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
private readonly fixSelectAllCommandMenuItemsCommand: FixSelectAllCommandMenuItemsCommand,
|
||||
private readonly migrateAiAgentTextToJsonResponseFormatCommand: MigrateAiAgentTextToJsonResponseFormatCommand,
|
||||
private readonly updateEditLayoutCommandMenuItemLabelCommand: UpdateEditLayoutCommandMenuItemLabelCommand,
|
||||
private readonly refactorNavigationCommandsCommand: RefactorNavigationCommandsCommand,
|
||||
private readonly dropWorkspaceMessagingFksCommand: DropWorkspaceMessagingFksCommand,
|
||||
private readonly migrateMessageFolderParentIdToExternalIdCommand: MigrateMessageFolderParentIdToExternalIdCommand,
|
||||
) {
|
||||
@@ -129,6 +132,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
this.fixSelectAllCommandMenuItemsCommand,
|
||||
this.migrateAiAgentTextToJsonResponseFormatCommand,
|
||||
this.updateEditLayoutCommandMenuItemLabelCommand,
|
||||
this.refactorNavigationCommandsCommand,
|
||||
this.dropWorkspaceMessagingFksCommand,
|
||||
this.migrateMessageFolderParentIdToExternalIdCommand,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user