Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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);
}
};
Expand Down Expand Up @@ -135,7 +141,7 @@ function ButtonExtendedProperties({resource, onChange}: ButtonExtendedProperties
{t('flows:core.buttonExtendedProperties.action.trigger', 'Trigger Action')}
</MenuItem>
<MenuItem value={ACTION_OPTIONS.SignOut}>
{t('flows:core.buttonExtendedProperties.action.signOut', 'Trigger Signout')}
{t('flows:core.buttonExtendedProperties.action.signOut', 'Sign Out Action')}
</MenuItem>
</Select>
<FormHelperText>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Resource>);

render(<ButtonExtendedProperties resource={resource} onChange={mockOnChange} />);

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<Resource>);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
Expand Down Expand Up @@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | undefined;
expect(component).not.toHaveProperty('actionType');
});

it('should normalize INPUT element properties', () => {
const flowData = createBaseFlowData([
{
Expand Down Expand Up @@ -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',
});
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion frontend/packages/i18n/src/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down
Loading