From 29aa6e85d685c29e9fd5d07b85e8443df8dee995 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Tue, 4 Aug 2026 17:48:06 +0200 Subject: [PATCH] fix(front): only show Discard Draft when workflow has a published version (#23756) ## Problem Draft workflows expose a **Discard Draft** action, but it fails when the draft is the *only* version of the workflow. The backend refuses the delete with `The initial version of a workflow can not be deleted` (guard in `validateWorkflowVersionForDeleteOne`), yet the action was still shown. The display condition and the delete-guard disagreed: - Display condition: `every(selectedRecords, "versions.length")` -> truthy when there is **at least one** version. - Delete guard: forbids deletion unless **another** non-deleted version exists. So a workflow whose only version is a draft showed the button, and clicking it hit a `FORBIDDEN` error. ## Fix Show the action only when the workflow has a published version to fall back to, which mirrors the backend guard: ``` every(selectedRecords, "lastPublishedVersionId") and everyEquals(selectedRecords, "currentVersion.status", "DRAFT") and noneDefined(selectedRecords, "deletedAt") ``` `lastPublishedVersionId` is a plain scalar already on the workflow. `every` (truthiness) is used rather than `everyDefined` because the field is an empty string for never-published workflows, and `everyDefined` would treat `""` as present. The command-menu evaluator reads records straight from the store, and the index/table view only fetches visible columns, so the enrichment provider now also backfills `lastPublishedVersionId` (already fetched by `useWorkflowsWithCurrentVersions`) to keep the condition reliable outside the record show page. ## Existing workspaces The standard-application full sync only runs at workspace creation, so editing the constant alone would fix new workspaces but leave existing ones showing the broken button. A `2.27.0` workspace upgrade command re-syncs the `discardDraftWorkflow` availability expression for existing workspaces, updating it only when it still equals the legacy `versions.length` value (so custom expressions are left untouched). Mirrors the existing 2-23 command-menu-item sync pattern. ## Notes - The `>`-style "more than one version" comparison is not expressible in the current `conditionalAvailabilityExpression` grammar (comparison operators only reach top-level scalars like `numberOfSelectedRecords`, not per-record paths). Gating on `lastPublishedVersionId` achieves the same intent without adding a parser helper. ## Test - Fresh workflow (single draft) -> Discard Draft hidden. - Publish, then edit to create a new draft -> Discard Draft shown and works. - Unit test on the sync-operations builder: updates the legacy expression, no-ops when already synced / custom / missing. --- ...uContextProviderWithWorkflowEnrichment.tsx | 2 + .../2-28-upgrade-version-command.module.ts | 17 +- ...orkflow-availability-expression.command.ts | 95 +++++++++++ ...ty-expression-sync-operations.util.spec.ts | 149 ++++++++++++++++++ ...ability-expression-sync-operations.util.ts | 42 +++++ .../standard-command-menu-item.constant.ts | 2 +- 6 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1786000000000-sync-discard-draft-workflow-availability-expression.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-28/utils/__tests__/build-sync-discard-draft-workflow-availability-expression-sync-operations.util.spec.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-28/utils/build-sync-discard-draft-workflow-availability-expression-sync-operations.util.ts diff --git a/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProviderWithWorkflowEnrichment.tsx b/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProviderWithWorkflowEnrichment.tsx index 96d84c001b..06266f74d1 100644 --- a/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProviderWithWorkflowEnrichment.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProviderWithWorkflowEnrichment.tsx @@ -42,6 +42,8 @@ export const CommandMenuContextProviderWithWorkflowEnrichment = ({ currentVersion: workflowWithCurrentVersion.currentVersion, versions: workflowWithCurrentVersion.versions, statuses: workflowWithCurrentVersion.statuses, + lastPublishedVersionId: + workflowWithCurrentVersion.lastPublishedVersionId, }; }, ); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-upgrade-version-command.module.ts index 3fb69ad5f5..57b94bc2d8 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-upgrade-version-command.module.ts @@ -2,10 +2,23 @@ import { Module } from '@nestjs/common'; import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; import { RepairOrphanCoreWorkflowVersionsCommand } from 'src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1785600000000-repair-orphan-core-workflow-versions.command'; +import { SyncDiscardDraftWorkflowAvailabilityExpressionCommand } from 'src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1786000000000-sync-discard-draft-workflow-availability-expression.command'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; +import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module'; +import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; @Module({ - imports: [WorkspaceCacheModule, WorkspaceIteratorModule], - providers: [RepairOrphanCoreWorkflowVersionsCommand], + imports: [ + ApplicationModule, + WorkspaceCacheModule, + WorkspaceMigrationModule, + WorkspaceMigrationRunnerModule, + WorkspaceIteratorModule, + ], + providers: [ + RepairOrphanCoreWorkflowVersionsCommand, + SyncDiscardDraftWorkflowAvailabilityExpressionCommand, + ], }) export class V2_28_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1786000000000-sync-discard-draft-workflow-availability-expression.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1786000000000-sync-discard-draft-workflow-availability-expression.command.ts new file mode 100644 index 0000000000..2d9ffa47e7 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1786000000000-sync-discard-draft-workflow-availability-expression.command.ts @@ -0,0 +1,95 @@ +import { Command } from 'nest-commander'; + +import { ProvisionedWorkspaceCommandRunner } from 'src/database/commands/command-runners/provisioned-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { buildDiscardDraftWorkflowCommandMenuItemsToUpdate } from 'src/database/commands/upgrade-version-command/2-28/utils/build-sync-discard-draft-workflow-availability-expression-sync-operations.util'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +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'; + +@RegisteredWorkspaceCommand('2.28.0', 1786000000000) +@Command({ + name: 'upgrade:2-28:sync-discard-draft-workflow-availability-expression', + description: + 'Gate the "Discard Draft" workflow command menu item on lastPublishedVersionId so it is hidden when the draft is the only version in existing workspaces', +}) +export class SyncDiscardDraftWorkflowAvailabilityExpressionCommand extends ProvisionedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly applicationService: ApplicationService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const { flatCommandMenuItemMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatCommandMenuItemMaps', + ]); + + const commandMenuItemsToUpdate = + buildDiscardDraftWorkflowCommandMenuItemsToUpdate({ + existingFlatCommandMenuItemMaps: flatCommandMenuItemMaps, + }); + + if (commandMenuItemsToUpdate.length === 0) { + this.logger.log( + `"Discard Draft" workflow command menu item availability expression already synced for workspace ${workspaceId}, skipping`, + ); + + return; + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Syncing "Discard Draft" workflow command menu item availability expression for workspace ${workspaceId}`, + ); + + if (isDryRun) { + return; + } + + const { twentyStandardFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunLegacyWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + commandMenuItem: { + flatEntityToCreate: [], + flatEntityToDelete: [], + flatEntityToUpdate: commandMenuItemsToUpdate, + }, + }, + workspaceId, + applicationUniversalIdentifier: + twentyStandardFlatApplication.universalIdentifier, + }, + ); + + if (validateAndBuildResult.status === 'fail') { + this.logger.error( + `Failed to sync "Discard Draft" workflow command menu item availability expression:\n${JSON.stringify(validateAndBuildResult, null, 2)}`, + ); + + throw new Error( + `Failed to sync "Discard Draft" workflow command menu item availability expression for workspace ${workspaceId}`, + ); + } + + this.logger.log( + `Successfully synced "Discard Draft" workflow command menu item availability expression for workspace ${workspaceId}`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/utils/__tests__/build-sync-discard-draft-workflow-availability-expression-sync-operations.util.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/utils/__tests__/build-sync-discard-draft-workflow-availability-expression-sync-operations.util.spec.ts new file mode 100644 index 0000000000..0e4a53121d --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/utils/__tests__/build-sync-discard-draft-workflow-availability-expression-sync-operations.util.spec.ts @@ -0,0 +1,149 @@ +import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application'; + +import { + buildDiscardDraftWorkflowCommandMenuItemsToUpdate, + LEGACY_DISCARD_DRAFT_WORKFLOW_AVAILABILITY_EXPRESSION, +} from 'src/database/commands/upgrade-version-command/2-28/utils/build-sync-discard-draft-workflow-availability-expression-sync-operations.util'; +import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant'; + +const APPLICATION_ID = 'application-id'; +const WORKSPACE_ID = 'workspace-id'; +const CREATED_AT = '2026-08-01T00:00:00.000Z'; + +const DISCARD_DRAFT_WORKFLOW_DEFINITION = + STANDARD_COMMAND_MENU_ITEMS.discardDraftWorkflow; + +// Independent literal so a regression in the standard definition is caught here +// rather than silently accepted by asserting the value against itself. +const EXPECTED_DISCARD_DRAFT_WORKFLOW_AVAILABILITY_EXPRESSION = + 'every(selectedRecords, "lastPublishedVersionId") and everyEquals(selectedRecords, "currentVersion.status", "DRAFT") and noneDefined(selectedRecords, "deletedAt")'; + +const buildFlatCommandMenuItemMaps = ( + flatCommandMenuItems: FlatCommandMenuItem[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatCommandMenuItems.map((flatCommandMenuItem) => [ + flatCommandMenuItem.universalIdentifier, + flatCommandMenuItem, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatCommandMenuItems.map((flatCommandMenuItem) => [ + flatCommandMenuItem.id, + flatCommandMenuItem.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: { + [APPLICATION_ID]: flatCommandMenuItems.map( + (flatCommandMenuItem) => flatCommandMenuItem.universalIdentifier, + ), + }, +}); + +const buildDiscardDraftWorkflowCommandMenuItem = ({ + conditionalAvailabilityExpression, +}: { + conditionalAvailabilityExpression: string | null; +}): FlatCommandMenuItem => ({ + id: 'discard-draft-workflow-command-menu-item-id', + universalIdentifier: DISCARD_DRAFT_WORKFLOW_DEFINITION.universalIdentifier, + applicationId: APPLICATION_ID, + applicationUniversalIdentifier: + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, + workspaceId: WORKSPACE_ID, + label: DISCARD_DRAFT_WORKFLOW_DEFINITION.label, + shortLabel: DISCARD_DRAFT_WORKFLOW_DEFINITION.shortLabel, + icon: DISCARD_DRAFT_WORKFLOW_DEFINITION.icon, + position: DISCARD_DRAFT_WORKFLOW_DEFINITION.position, + isPinned: DISCARD_DRAFT_WORKFLOW_DEFINITION.isPinned, + availabilityType: DISCARD_DRAFT_WORKFLOW_DEFINITION.availabilityType, + conditionalAvailabilityExpression, + frontComponentId: null, + frontComponentUniversalIdentifier: null, + engineComponentKey: DISCARD_DRAFT_WORKFLOW_DEFINITION.engineComponentKey, + payload: null, + hotKeys: null, + workflowVersionId: null, + availabilityObjectMetadataId: 'workflow-object-metadata-id', + availabilityObjectMetadataUniversalIdentifier: + DISCARD_DRAFT_WORKFLOW_DEFINITION.availabilityObjectMetadataUniversalIdentifier, + pageLayoutId: null, + pageLayoutUniversalIdentifier: null, + isActive: true, + isSystemSideEffect: false, + overrides: null, + universalOverrides: null, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, +}); + +describe('buildDiscardDraftWorkflowCommandMenuItemsToUpdate', () => { + it('rewrites the legacy versions.length gate to the lastPublishedVersionId gate', () => { + const legacyDiscardDraftWorkflowCommandMenuItem = + buildDiscardDraftWorkflowCommandMenuItem({ + conditionalAvailabilityExpression: + LEGACY_DISCARD_DRAFT_WORKFLOW_AVAILABILITY_EXPRESSION, + }); + + const result = buildDiscardDraftWorkflowCommandMenuItemsToUpdate({ + existingFlatCommandMenuItemMaps: buildFlatCommandMenuItemMaps([ + legacyDiscardDraftWorkflowCommandMenuItem, + ]), + }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: legacyDiscardDraftWorkflowCommandMenuItem.id, + conditionalAvailabilityExpression: + EXPECTED_DISCARD_DRAFT_WORKFLOW_AVAILABILITY_EXPRESSION, + }); + }); + + it('syncs to the lastPublishedVersionId gate defined in the standard command menu item', () => { + expect( + DISCARD_DRAFT_WORKFLOW_DEFINITION.conditionalAvailabilityExpression, + ).toBe(EXPECTED_DISCARD_DRAFT_WORKFLOW_AVAILABILITY_EXPRESSION); + }); + + it('does not update the command menu item when the expression is already synced', () => { + const syncedDiscardDraftWorkflowCommandMenuItem = + buildDiscardDraftWorkflowCommandMenuItem({ + conditionalAvailabilityExpression: + EXPECTED_DISCARD_DRAFT_WORKFLOW_AVAILABILITY_EXPRESSION, + }); + + const result = buildDiscardDraftWorkflowCommandMenuItemsToUpdate({ + existingFlatCommandMenuItemMaps: buildFlatCommandMenuItemMaps([ + syncedDiscardDraftWorkflowCommandMenuItem, + ]), + }); + + expect(result).toHaveLength(0); + }); + + it('does not update an unrelated custom availability expression', () => { + const customDiscardDraftWorkflowCommandMenuItem = + buildDiscardDraftWorkflowCommandMenuItem({ + conditionalAvailabilityExpression: + 'everyEquals(selectedRecords, "currentVersion.status", "DRAFT")', + }); + + const result = buildDiscardDraftWorkflowCommandMenuItemsToUpdate({ + existingFlatCommandMenuItemMaps: buildFlatCommandMenuItemMaps([ + customDiscardDraftWorkflowCommandMenuItem, + ]), + }); + + expect(result).toHaveLength(0); + }); + + it('does not update when the discard draft workflow command menu item is missing', () => { + const result = buildDiscardDraftWorkflowCommandMenuItemsToUpdate({ + existingFlatCommandMenuItemMaps: buildFlatCommandMenuItemMaps([]), + }); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/utils/build-sync-discard-draft-workflow-availability-expression-sync-operations.util.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/utils/build-sync-discard-draft-workflow-availability-expression-sync-operations.util.ts new file mode 100644 index 0000000000..5b7016b469 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/utils/build-sync-discard-draft-workflow-availability-expression-sync-operations.util.ts @@ -0,0 +1,42 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant'; + +export const LEGACY_DISCARD_DRAFT_WORKFLOW_AVAILABILITY_EXPRESSION = + 'every(selectedRecords, "versions.length") and everyEquals(selectedRecords, "currentVersion.status", "DRAFT") and noneDefined(selectedRecords, "deletedAt")'; + +const DISCARD_DRAFT_WORKFLOW_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER = + STANDARD_COMMAND_MENU_ITEMS.discardDraftWorkflow.universalIdentifier; + +const DISCARD_DRAFT_WORKFLOW_AVAILABILITY_EXPRESSION = + STANDARD_COMMAND_MENU_ITEMS.discardDraftWorkflow + .conditionalAvailabilityExpression; + +export const buildDiscardDraftWorkflowCommandMenuItemsToUpdate = ({ + existingFlatCommandMenuItemMaps, +}: { + existingFlatCommandMenuItemMaps: FlatEntityMaps; +}): FlatCommandMenuItem[] => { + const existingDiscardDraftWorkflowCommandMenuItem = + existingFlatCommandMenuItemMaps.byUniversalIdentifier[ + DISCARD_DRAFT_WORKFLOW_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER + ]; + + if ( + !isDefined(existingDiscardDraftWorkflowCommandMenuItem) || + existingDiscardDraftWorkflowCommandMenuItem.conditionalAvailabilityExpression !== + LEGACY_DISCARD_DRAFT_WORKFLOW_AVAILABILITY_EXPRESSION + ) { + return []; + } + + return [ + { + ...existingDiscardDraftWorkflowCommandMenuItem, + conditionalAvailabilityExpression: + DISCARD_DRAFT_WORKFLOW_AVAILABILITY_EXPRESSION, + }, + ]; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts index 95baa03617..243b9bb1ce 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts @@ -379,7 +379,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { shortLabel: 'Discard Draft', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: - 'every(selectedRecords, "versions.length") and everyEquals(selectedRecords, "currentVersion.status", "DRAFT") and noneDefined(selectedRecords, "deletedAt")', + 'every(selectedRecords, "lastPublishedVersionId") and everyEquals(selectedRecords, "currentVersion.status", "DRAFT") and noneDefined(selectedRecords, "deletedAt")', availabilityObjectMetadataUniversalIdentifier: STANDARD_OBJECTS.workflow.universalIdentifier, frontComponentUniversalIdentifier: null,