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.
This commit is contained in:
Thomas Trompette
2026-08-04 17:48:06 +02:00
committed by GitHub
parent 3833015626
commit 29aa6e85d6
6 changed files with 304 additions and 3 deletions
@@ -42,6 +42,8 @@ export const CommandMenuContextProviderWithWorkflowEnrichment = ({
currentVersion: workflowWithCurrentVersion.currentVersion, currentVersion: workflowWithCurrentVersion.currentVersion,
versions: workflowWithCurrentVersion.versions, versions: workflowWithCurrentVersion.versions,
statuses: workflowWithCurrentVersion.statuses, statuses: workflowWithCurrentVersion.statuses,
lastPublishedVersionId:
workflowWithCurrentVersion.lastPublishedVersionId,
}; };
}, },
); );
@@ -2,10 +2,23 @@ import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; 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 { 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 { 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({ @Module({
imports: [WorkspaceCacheModule, WorkspaceIteratorModule], imports: [
providers: [RepairOrphanCoreWorkflowVersionsCommand], ApplicationModule,
WorkspaceCacheModule,
WorkspaceMigrationModule,
WorkspaceMigrationRunnerModule,
WorkspaceIteratorModule,
],
providers: [
RepairOrphanCoreWorkflowVersionsCommand,
SyncDiscardDraftWorkflowAvailabilityExpressionCommand,
],
}) })
export class V2_28_UpgradeVersionCommandModule {} export class V2_28_UpgradeVersionCommandModule {}
@@ -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<void> {
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}`,
);
}
}
@@ -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<FlatCommandMenuItem> => ({
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([]);
});
});
@@ -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>;
}): 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,
},
];
};
@@ -379,7 +379,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
shortLabel: 'Discard Draft', shortLabel: 'Discard Draft',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression: 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: availabilityObjectMetadataUniversalIdentifier:
STANDARD_OBJECTS.workflow.universalIdentifier, STANDARD_OBJECTS.workflow.universalIdentifier,
frontComponentUniversalIdentifier: null, frontComponentUniversalIdentifier: null,