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:
+2
@@ -42,6 +42,8 @@ export const CommandMenuContextProviderWithWorkflowEnrichment = ({
|
||||
currentVersion: workflowWithCurrentVersion.currentVersion,
|
||||
versions: workflowWithCurrentVersion.versions,
|
||||
statuses: workflowWithCurrentVersion.statuses,
|
||||
lastPublishedVersionId:
|
||||
workflowWithCurrentVersion.lastPublishedVersionId,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
+15
-2
@@ -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 {}
|
||||
|
||||
+95
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+149
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+42
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user