d341d0d624
- 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
380 lines
14 KiB
TypeScript
380 lines
14 KiB
TypeScript
import { isNonEmptyString } from '@sniptt/guards';
|
|
import { Command } from 'nest-commander';
|
|
import { FeatureFlagKey } from 'twenty-shared/types';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
|
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
|
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
|
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 { 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 { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
|
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
|
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
|
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
|
|
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
|
|
import {
|
|
WorkflowVersionStatus,
|
|
type WorkflowVersionWorkspaceEntity,
|
|
} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
|
|
import { type WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
|
|
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
|
|
import {
|
|
type WorkflowManualTrigger,
|
|
WorkflowTriggerType,
|
|
} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
|
|
|
|
@Command({
|
|
name: 'upgrade:1-20:backfill-command-menu-items',
|
|
description:
|
|
'Backfill missing standard and trigger workflow version command menu items for existing workspaces and enable IS_COMMAND_MENU_ITEM_ENABLED feature flag',
|
|
})
|
|
export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
|
constructor(
|
|
private readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
|
|
private readonly applicationService: ApplicationService,
|
|
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
|
private readonly featureFlagService: FeatureFlagService,
|
|
private readonly workspaceCacheService: WorkspaceCacheService,
|
|
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
|
|
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
|
) {
|
|
super(workspaceIteratorService);
|
|
}
|
|
|
|
override async runOnWorkspace({
|
|
workspaceId,
|
|
options,
|
|
}: RunOnWorkspaceArgs): Promise<void> {
|
|
const isDryRun = options.dryRun ?? false;
|
|
|
|
this.logger.log(
|
|
`${isDryRun ? '[DRY RUN] ' : ''}Starting backfill of command menu items for workspace ${workspaceId}`,
|
|
);
|
|
|
|
const isFeatureFlagAlreadyEnabled =
|
|
await this.featureFlagService.isFeatureEnabled(
|
|
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
|
|
workspaceId,
|
|
);
|
|
|
|
if (isFeatureFlagAlreadyEnabled) {
|
|
this.logger.log(
|
|
`IS_COMMAND_MENU_ITEM_ENABLED already enabled for workspace ${workspaceId}, skipping`,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
const { twentyStandardFlatApplication, workspaceCustomFlatApplication } =
|
|
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
|
{ workspaceId },
|
|
);
|
|
|
|
const standardCommandMenuItems =
|
|
await this.computeStandardCommandMenuItemsToCreate(
|
|
workspaceId,
|
|
twentyStandardFlatApplication,
|
|
);
|
|
|
|
const triggerWorkflowVersionCommandMenuItems =
|
|
await this.computeTriggerWorkflowVersionCommandMenuItemsToCreate(
|
|
workspaceId,
|
|
workspaceCustomFlatApplication,
|
|
);
|
|
|
|
const totalCount =
|
|
standardCommandMenuItems.length +
|
|
triggerWorkflowVersionCommandMenuItems.length;
|
|
|
|
if (totalCount === 0) {
|
|
this.logger.log(
|
|
`No missing command menu items for workspace ${workspaceId}`,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
this.logger.log(
|
|
`Found ${totalCount} missing command menu item(s) for workspace ${workspaceId} (${standardCommandMenuItems.length} standard, ${triggerWorkflowVersionCommandMenuItems.length} trigger workflow version)`,
|
|
);
|
|
|
|
if (isDryRun) {
|
|
this.logger.log(
|
|
`[DRY RUN] Would create ${totalCount} command menu item(s) for workspace ${workspaceId}`,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if (standardCommandMenuItems.length > 0) {
|
|
await this.createCommandMenuItems({
|
|
workspaceId,
|
|
flatCommandMenuItemsToCreate: standardCommandMenuItems,
|
|
applicationUniversalIdentifier:
|
|
twentyStandardFlatApplication.universalIdentifier,
|
|
});
|
|
}
|
|
|
|
if (triggerWorkflowVersionCommandMenuItems.length > 0) {
|
|
await this.createCommandMenuItems({
|
|
workspaceId,
|
|
flatCommandMenuItemsToCreate: triggerWorkflowVersionCommandMenuItems,
|
|
applicationUniversalIdentifier:
|
|
workspaceCustomFlatApplication.universalIdentifier,
|
|
});
|
|
}
|
|
|
|
await this.featureFlagService.enableFeatureFlags(
|
|
[FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED],
|
|
workspaceId,
|
|
);
|
|
|
|
this.logger.log(
|
|
`Successfully backfilled ${totalCount} command menu item(s) for workspace ${workspaceId}`,
|
|
);
|
|
}
|
|
|
|
private async computeStandardCommandMenuItemsToCreate(
|
|
workspaceId: string,
|
|
twentyStandardFlatApplication: FlatApplication,
|
|
): Promise<FlatCommandMenuItem[]> {
|
|
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
|
|
computeTwentyStandardApplicationAllFlatEntityMaps({
|
|
shouldIncludeRecordPageLayouts: true,
|
|
now: new Date().toISOString(),
|
|
workspaceId,
|
|
twentyStandardApplicationId: twentyStandardFlatApplication.id,
|
|
});
|
|
|
|
const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } =
|
|
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
|
'flatCommandMenuItemMaps',
|
|
]);
|
|
|
|
const commandMenuItemsToCreate = Object.values(
|
|
standardAllFlatEntityMaps.flatCommandMenuItemMaps.byUniversalIdentifier,
|
|
)
|
|
.filter(isDefined)
|
|
.filter(
|
|
(commandMenuItem) =>
|
|
!isDefined(
|
|
existingFlatCommandMenuItemMaps.byUniversalIdentifier[
|
|
commandMenuItem.universalIdentifier
|
|
],
|
|
),
|
|
);
|
|
|
|
if (commandMenuItemsToCreate.length > 0) {
|
|
this.logger.log(
|
|
`Found ${commandMenuItemsToCreate.length} missing standard command menu item(s) for workspace ${workspaceId}`,
|
|
);
|
|
}
|
|
|
|
return commandMenuItemsToCreate;
|
|
}
|
|
|
|
private async computeTriggerWorkflowVersionCommandMenuItemsToCreate(
|
|
workspaceId: string,
|
|
workspaceCustomFlatApplication: FlatApplication,
|
|
): Promise<FlatCommandMenuItem[]> {
|
|
const authContext = buildSystemAuthContext(workspaceId);
|
|
|
|
return await this.twentyORMGlobalManager.executeInWorkspaceContext(
|
|
async () => {
|
|
const workflowVersionRepository =
|
|
await this.twentyORMGlobalManager.getRepository<WorkflowVersionWorkspaceEntity>(
|
|
workspaceId,
|
|
'workflowVersion',
|
|
{ shouldBypassPermissionChecks: true },
|
|
);
|
|
|
|
const activeWorkflowVersions = await workflowVersionRepository.find({
|
|
where: { status: WorkflowVersionStatus.ACTIVE },
|
|
});
|
|
|
|
const manualTriggerVersions = activeWorkflowVersions.filter(
|
|
(version) =>
|
|
isDefined(version.trigger) &&
|
|
version.trigger.type === WorkflowTriggerType.MANUAL,
|
|
);
|
|
|
|
if (manualTriggerVersions.length === 0) {
|
|
this.logger.log(
|
|
`No active workflow versions with manual triggers for workspace ${workspaceId}`,
|
|
);
|
|
|
|
return [];
|
|
}
|
|
|
|
const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } =
|
|
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
|
'flatCommandMenuItemMaps',
|
|
]);
|
|
|
|
const existingWorkflowVersionIds = new Set(
|
|
Object.values(existingFlatCommandMenuItemMaps.byUniversalIdentifier)
|
|
.filter(isDefined)
|
|
.map((item) => item.workflowVersionId)
|
|
.filter(isDefined),
|
|
);
|
|
|
|
const workflowRepository =
|
|
await this.twentyORMGlobalManager.getRepository<WorkflowWorkspaceEntity>(
|
|
workspaceId,
|
|
'workflow',
|
|
{ shouldBypassPermissionChecks: true },
|
|
);
|
|
|
|
const flatCommandMenuItemsToCreate: FlatCommandMenuItem[] = [];
|
|
|
|
for (const workflowVersion of manualTriggerVersions) {
|
|
if (existingWorkflowVersionIds.has(workflowVersion.id)) {
|
|
continue;
|
|
}
|
|
|
|
const workflow = await workflowRepository.findOne({
|
|
where: { id: workflowVersion.workflowId },
|
|
});
|
|
|
|
const label =
|
|
workflow && isNonEmptyString(workflow.name)
|
|
? workflow.name
|
|
: 'Manual Trigger';
|
|
|
|
const trigger = workflowVersion.trigger as WorkflowManualTrigger;
|
|
|
|
const {
|
|
availabilityType,
|
|
availabilityObjectMetadataId,
|
|
availabilityObjectMetadataUniversalIdentifier,
|
|
} = await this.resolveManualTriggerAvailability(trigger, workspaceId);
|
|
|
|
const id = uuidv4();
|
|
const now = new Date().toISOString();
|
|
|
|
flatCommandMenuItemsToCreate.push({
|
|
id,
|
|
universalIdentifier: id,
|
|
workflowVersionId: workflowVersion.id,
|
|
frontComponentId: null,
|
|
frontComponentUniversalIdentifier: null,
|
|
engineComponentKey: EngineComponentKey.TRIGGER_WORKFLOW_VERSION,
|
|
label,
|
|
shortLabel: label,
|
|
icon: trigger.settings.icon ?? null,
|
|
isPinned: trigger.settings.isPinned ?? false,
|
|
position: 0,
|
|
payload: null,
|
|
hotKeys: null,
|
|
availabilityType,
|
|
availabilityObjectMetadataId: availabilityObjectMetadataId ?? null,
|
|
availabilityObjectMetadataUniversalIdentifier,
|
|
conditionalAvailabilityExpression: null,
|
|
workspaceId,
|
|
applicationId: workspaceCustomFlatApplication.id,
|
|
applicationUniversalIdentifier:
|
|
workspaceCustomFlatApplication.universalIdentifier,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
|
|
if (flatCommandMenuItemsToCreate.length > 0) {
|
|
this.logger.log(
|
|
`Found ${flatCommandMenuItemsToCreate.length} missing trigger workflow version command menu item(s) for workspace ${workspaceId}`,
|
|
);
|
|
}
|
|
|
|
return flatCommandMenuItemsToCreate;
|
|
},
|
|
authContext,
|
|
);
|
|
}
|
|
|
|
private async createCommandMenuItems({
|
|
workspaceId,
|
|
flatCommandMenuItemsToCreate,
|
|
applicationUniversalIdentifier,
|
|
}: {
|
|
workspaceId: string;
|
|
flatCommandMenuItemsToCreate: FlatCommandMenuItem[];
|
|
applicationUniversalIdentifier: string;
|
|
}): Promise<void> {
|
|
const validateAndBuildResult =
|
|
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
|
{
|
|
allFlatEntityOperationByMetadataName: {
|
|
commandMenuItem: {
|
|
flatEntityToCreate: flatCommandMenuItemsToCreate,
|
|
flatEntityToDelete: [],
|
|
flatEntityToUpdate: [],
|
|
},
|
|
},
|
|
workspaceId,
|
|
applicationUniversalIdentifier,
|
|
},
|
|
);
|
|
|
|
if (validateAndBuildResult.status === 'fail') {
|
|
this.logger.error(
|
|
`Failed to backfill command menu items:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
|
|
);
|
|
|
|
throw new Error(
|
|
`Failed to backfill command menu items for workspace ${workspaceId}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async resolveManualTriggerAvailability(
|
|
trigger: WorkflowManualTrigger,
|
|
workspaceId: string,
|
|
): Promise<{
|
|
availabilityType: CommandMenuItemAvailabilityType;
|
|
availabilityObjectMetadataId: string | undefined;
|
|
availabilityObjectMetadataUniversalIdentifier: string | null;
|
|
}> {
|
|
const availability = trigger.settings.availability;
|
|
|
|
if (!isDefined(availability) || availability.type === 'GLOBAL') {
|
|
return {
|
|
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
|
|
availabilityObjectMetadataId: undefined,
|
|
availabilityObjectMetadataUniversalIdentifier: null,
|
|
};
|
|
}
|
|
|
|
const { objectIdByNameSingular, flatObjectMetadataMaps } =
|
|
await this.workflowCommonWorkspaceService.getFlatEntityMaps(workspaceId);
|
|
|
|
const objectId = objectIdByNameSingular[availability.objectNameSingular];
|
|
|
|
if (!isDefined(objectId)) {
|
|
this.logger.warn(
|
|
`Object metadata not found for "${availability.objectNameSingular}" in workspace ${workspaceId}, falling back to GLOBAL`,
|
|
);
|
|
|
|
return {
|
|
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
|
|
availabilityObjectMetadataId: undefined,
|
|
availabilityObjectMetadataUniversalIdentifier: null,
|
|
};
|
|
}
|
|
|
|
const objectUniversalIdentifier =
|
|
flatObjectMetadataMaps.universalIdentifierById[objectId] ?? null;
|
|
|
|
return {
|
|
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
|
|
availabilityObjectMetadataId: objectId,
|
|
availabilityObjectMetadataUniversalIdentifier: objectUniversalIdentifier,
|
|
};
|
|
}
|
|
}
|