diff --git a/frontend/apps/console/src/features/flows/components/resource-property-panel/extended-properties/ButtonExtendedProperties.tsx b/frontend/apps/console/src/features/flows/components/resource-property-panel/extended-properties/ButtonExtendedProperties.tsx index cc5da513a4..6ca0e077a7 100644 --- a/frontend/apps/console/src/features/flows/components/resource-property-panel/extended-properties/ButtonExtendedProperties.tsx +++ b/frontend/apps/console/src/features/flows/components/resource-property-panel/extended-properties/ButtonExtendedProperties.tsx @@ -28,13 +28,17 @@ import {ActionEventTypes, PromptActionTypes} from '@/features/flows/models/eleme * button's `eventType`; `SignOut` is a submit button that additionally raises * the `SIGN_OUT_CONFIRM` prompt action, so one selection maps onto two fields. * + * `SignOut` deliberately carries the same literal that is persisted as + * `prompts[].action.type`, so the value selected here and the value in the flow + * definition are one vocabulary rather than a UI-only alias. + * * The remaining `ActionEventTypes` (navigate, cancel, reset, back) are handled * by the SDK renderers but deliberately not offered here. */ const ACTION_OPTIONS = { Submit: 'SUBMIT', Trigger: 'TRIGGER', - SignOut: 'SIGN_OUT', + SignOut: PromptActionTypes.SignOutConfirm, } as const; type ActionOption = (typeof ACTION_OPTIONS)[keyof typeof ACTION_OPTIONS]; @@ -75,8 +79,10 @@ function ButtonExtendedProperties({resource, onChange}: ButtonExtendedProperties onChange('eventType', nextAction, resource); // Clearing keeps the button from silently staying a sign-out confirmation - // after the author picks a plain action. - if (element?.actionType) { + // after the author picks a plain action. Only the type this selector owns is + // cleared, so an action type it does not model (e.g. REJECT, authored in the + // flow definition directly) is left untouched rather than discarded. + if (element?.actionType === PromptActionTypes.SignOutConfirm) { onChange('actionType', '', resource); } }; @@ -135,7 +141,7 @@ function ButtonExtendedProperties({resource, onChange}: ButtonExtendedProperties {t('flows:core.buttonExtendedProperties.action.trigger', 'Trigger Action')} - {t('flows:core.buttonExtendedProperties.action.signOut', 'Trigger Signout')} + {t('flows:core.buttonExtendedProperties.action.signOut', 'Sign Out Action')} diff --git a/frontend/apps/console/src/features/flows/components/resource-property-panel/extended-properties/__tests__/ButtonExtendedProperties.test.tsx b/frontend/apps/console/src/features/flows/components/resource-property-panel/extended-properties/__tests__/ButtonExtendedProperties.test.tsx index f9afb2cc9e..995ab9e966 100644 --- a/frontend/apps/console/src/features/flows/components/resource-property-panel/extended-properties/__tests__/ButtonExtendedProperties.test.tsx +++ b/frontend/apps/console/src/features/flows/components/resource-property-panel/extended-properties/__tests__/ButtonExtendedProperties.test.tsx @@ -276,6 +276,24 @@ describe('ButtonExtendedProperties', () => { expect(mockOnChange).toHaveBeenCalledWith('actionType', '', resource); }); + it('should preserve an action type the selector does not model', async () => { + // REJECT is a valid prompt action type with no option here. Clearing it would silently + // discard a type authored directly in the flow definition. + const user = userEvent.setup(); + const resource = createMockResource({ + actionType: 'REJECT', + eventType: 'SUBMIT', + } as Partial); + + render(); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByRole('option', {name: 'flows:core.buttonExtendedProperties.action.trigger'})); + + expect(mockOnChange).toHaveBeenCalledWith('eventType', 'TRIGGER', resource); + expect(mockOnChange).not.toHaveBeenCalledWith('actionType', '', resource); + }); + it('should not clear the action type when it was never set', async () => { const user = userEvent.setup(); const resource = createMockResource({eventType: 'TRIGGER'} as Partial); diff --git a/frontend/apps/console/src/features/flows/models/responses.ts b/frontend/apps/console/src/features/flows/models/responses.ts index 65e09dfa64..52e454394b 100644 --- a/frontend/apps/console/src/features/flows/models/responses.ts +++ b/frontend/apps/console/src/features/flows/models/responses.ts @@ -151,6 +151,12 @@ export interface FlowNodeAction { * ID of the next node to navigate to */ nextNode: string; + /** + * Semantic action type forwarded to the next node's executor (e.g. `SUBMIT`, `REJECT`, + * `SIGN_OUT_CONFIRM`). Restored onto the element as `actionType` on load so a definition + * authored outside the builder keeps its type when the flow is saved again. + */ + type?: string; /** * Executor configuration for actions that trigger executors */ diff --git a/frontend/apps/console/src/features/flows/utils/__tests__/flowToCanvasTransformer.test.ts b/frontend/apps/console/src/features/flows/utils/__tests__/flowToCanvasTransformer.test.ts index 73b913292b..ea14969644 100644 --- a/frontend/apps/console/src/features/flows/utils/__tests__/flowToCanvasTransformer.test.ts +++ b/frontend/apps/console/src/features/flows/utils/__tests__/flowToCanvasTransformer.test.ts @@ -21,6 +21,7 @@ import VisualFlowConstants from '../../constants/VisualFlowConstants'; import type {FlowDefinitionResponse, FlowNode} from '../../models/responses'; import {StaticStepTypes, StepTypes} from '../../models/steps'; import {transformFlowToCanvas} from '../flowToCanvasTransformer'; +import {transformReactFlow} from '../reactFlowTransformer'; describe('flowToCanvasTransformer', () => { const createBaseFlowData = (nodes: FlowNode[]): FlowDefinitionResponse => ({ @@ -462,6 +463,89 @@ describe('flowToCanvasTransformer', () => { }); }); + it("should restore the prompt action's type onto the element", () => { + // A definition authored outside the builder carries the type only on the prompt action. + // Without restoring it the property panel cannot show it and serialization drops it. + const flowData = createBaseFlowData([ + { + id: 'prompt-node', + type: 'PROMPT', + meta: { + components: [{id: 'action_confirm', type: 'ACTION', label: 'Sign out'}], + }, + prompts: [{action: {ref: 'action_confirm', type: 'SIGN_OUT_CONFIRM', nextNode: 'next-node'}}], + layout: {position: {x: 0, y: 0}, size: {width: 300, height: 200}}, + }, + ]); + + const result = transformFlowToCanvas(flowData); + + const component = result.nodes[0].data.components?.[0] as Record | undefined; + expect(component?.actionType).toBe('SIGN_OUT_CONFIRM'); + }); + + it('should restore the type when the element carries a cleared action type', () => { + // Clearing the Action selector writes an empty string rather than dropping the key, and + // cleanComponents keeps it, so a definition saved after a clear carries actionType: ''. + // That means no type, so it must not block the backfill. + const flowData = createBaseFlowData([ + { + id: 'prompt-node', + type: 'PROMPT', + meta: { + components: [{id: 'action_confirm', type: 'ACTION', actionType: '', label: 'Sign out'}], + }, + prompts: [{action: {ref: 'action_confirm', type: 'SIGN_OUT_CONFIRM', nextNode: 'next-node'}}], + layout: {position: {x: 0, y: 0}, size: {width: 300, height: 200}}, + }, + ]); + + const result = transformFlowToCanvas(flowData); + + const component = result.nodes[0].data.components?.[0] as Record | undefined; + expect(component?.actionType).toBe('SIGN_OUT_CONFIRM'); + }); + + it('should keep an action type already on the element over the prompt action', () => { + // The element is the authoring surface, so an unwired button that still carries a type + // must not have it overwritten by a stale prompt action. + const flowData = createBaseFlowData([ + { + id: 'prompt-node', + type: 'PROMPT', + meta: { + components: [{id: 'action_confirm', type: 'ACTION', actionType: 'REJECT', label: 'No'}], + }, + prompts: [{action: {ref: 'action_confirm', type: 'SIGN_OUT_CONFIRM', nextNode: 'next-node'}}], + layout: {position: {x: 0, y: 0}, size: {width: 300, height: 200}}, + }, + ]); + + const result = transformFlowToCanvas(flowData); + + const component = result.nodes[0].data.components?.[0] as Record | undefined; + expect(component?.actionType).toBe('REJECT'); + }); + + it('should leave the element without an action type when the prompt action has none', () => { + const flowData = createBaseFlowData([ + { + id: 'prompt-node', + type: 'PROMPT', + meta: { + components: [{id: 'submit-btn', type: 'ACTION', label: 'Submit'}], + }, + prompts: [{action: {ref: 'submit-btn', nextNode: 'next-node'}}], + layout: {position: {x: 0, y: 0}, size: {width: 300, height: 200}}, + }, + ]); + + const result = transformFlowToCanvas(flowData); + + const component = result.nodes[0].data.components?.[0] as Record | undefined; + expect(component).not.toHaveProperty('actionType'); + }); + it('should normalize INPUT element properties', () => { const flowData = createBaseFlowData([ { @@ -679,5 +763,42 @@ describe('flowToCanvasTransformer', () => { expect(result.edges).toHaveLength(0); }); }); + + describe('Action Type Round Trip', () => { + const signOutFlow = (): FlowDefinitionResponse => + createBaseFlowData([ + { + id: 'prompt_confirm', + type: 'PROMPT', + meta: { + components: [{id: 'action_confirm', type: 'ACTION', label: 'Sign out'}], + }, + prompts: [{action: {ref: 'action_confirm', type: 'SIGN_OUT_CONFIRM', nextNode: 'session_signout'}}], + layout: {position: {x: 0, y: 0}, size: {width: 300, height: 200}}, + }, + { + id: 'session_signout', + type: 'TASK_EXECUTION', + executor: {name: 'SessionSignOutExecutor'}, + layout: {position: {x: 400, y: 0}, size: {width: 200, height: 100}}, + }, + ]); + + it('should keep the action type when a definition is loaded and serialized again', () => { + // Opening a flow in the builder and saving it must not change its meaning. Before the type + // was restored onto the element, this round trip silently dropped it and a sign-out flow + // regressed into an endless confirmation loop. + const canvas = transformFlowToCanvas(signOutFlow()); + + const saved = transformReactFlow({edges: canvas.edges, nodes: canvas.nodes}); + + const prompt = saved.nodes.find((node) => node.id === 'prompt_confirm'); + expect(prompt?.prompts?.[0].action).toMatchObject({ + ref: 'action_confirm', + nextNode: 'session_signout', + type: 'SIGN_OUT_CONFIRM', + }); + }); + }); }); }); diff --git a/frontend/apps/console/src/features/flows/utils/flowToCanvasTransformer.ts b/frontend/apps/console/src/features/flows/utils/flowToCanvasTransformer.ts index b6a8c34026..3afe21be6f 100644 --- a/frontend/apps/console/src/features/flows/utils/flowToCanvasTransformer.ts +++ b/frontend/apps/console/src/features/flows/utils/flowToCanvasTransformer.ts @@ -123,6 +123,13 @@ function restoreButtonAction( if (matchingAction) { return { ...component, + // Backfill the prompt action's type onto the element, which is where the property panel reads + // it from and where serialization projects it back out of. Only when the element does not + // already carry one: an unwired button keeps its type on the element alone, since a prompt + // action is only emitted once the button has a nextNode. Clearing the selector writes an empty + // string rather than dropping the key, and that empty string is persisted, so treat it as no + // type rather than as a type worth preserving. + ...(matchingAction.type && !component.actionType ? {actionType: matchingAction.type} : {}), action: { type: matchingAction.executor ? 'EXECUTOR' : 'NEXT', onSuccess: matchingAction.nextNode, diff --git a/frontend/packages/i18n/src/locales/en-US.ts b/frontend/packages/i18n/src/locales/en-US.ts index 43a184cf76..44acf66475 100644 --- a/frontend/packages/i18n/src/locales/en-US.ts +++ b/frontend/packages/i18n/src/locales/en-US.ts @@ -3821,7 +3821,7 @@ const translations = { 'core.buttonExtendedProperties.action.label': 'Action', 'core.buttonExtendedProperties.action.submit': 'Submit Form', 'core.buttonExtendedProperties.action.trigger': 'Trigger Action', - 'core.buttonExtendedProperties.action.signOut': 'Trigger Signout', + 'core.buttonExtendedProperties.action.signOut': 'Sign Out Action', 'core.buttonExtendedProperties.action.hint': 'What happens when the button is activated', 'core.buttonExtendedProperties.startIcon.label': 'Start Icon', 'core.buttonExtendedProperties.startIcon.placeholder': 'Enter icon path (e.g., assets/images/icons/icon.svg)',