From 0c80030ed9321a8393441bb9ef2a183f1ee72546 Mon Sep 17 00:00:00 2001 From: Riley Evans Date: Tue, 30 Jun 2026 11:21:54 -0500 Subject: [PATCH 1/6] feat(designer): make per-action code view editable Allow editing an action's JSON inline from the node-details Code view tab with live JSON validation and save/discard, without going to the whole-workflow code view (issue #9293). - EditableCodeView: controlled editor with save/discard + validation slot - updateNodeFromCodeView thunk: re-initializes only the edited node and merges into operation/token state (preserves other nodes' edits) - replaceOperationDefinition reducer: persists definition + marks dirty - codeViewTab: stateful container; reads useStore().getState() so the post-save refetch serializes fresh state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Localize/lang/strings.json | 8 ++ .../src/lib/peek/editableCodeView.tsx | 112 ++++++++++++++++ libs/designer-ui/src/lib/peek/index.tsx | 2 + .../bjsworkflow/operationdeserializer.ts | 8 +- .../bjsworkflow/updateNodeFromCodeView.ts | 125 ++++++++++++++++++ .../workflow/__test__/workflowSlice.spec.ts | 56 ++++++++ .../lib/core/state/workflow/workflowSlice.ts | 12 ++ .../nodeDetailsPanel/tabs/codeViewTab.tsx | 113 ++++++++++++++-- 8 files changed, 423 insertions(+), 13 deletions(-) create mode 100644 libs/designer-ui/src/lib/peek/editableCodeView.tsx create mode 100644 libs/designer/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts diff --git a/Localize/lang/strings.json b/Localize/lang/strings.json index cd42d3c7310..4a15f18d96a 100644 --- a/Localize/lang/strings.json +++ b/Localize/lang/strings.json @@ -1819,6 +1819,7 @@ "XOAcjQ": "(UTC+03:00) Nairobi", "XOzn/3": "Connection name", "XPBoDw": "Select an option", + "XQ3pyc": "Invalid JSON. Fix the errors before saving.", "XQ4OCV": "(UTC+03:00) Baghdad", "XR4Sd/": "Like", "XR5izH": "Connected", @@ -3782,6 +3783,7 @@ "_XOAcjQ.comment": "Time zone value ", "_XOzn/3.comment": "This is for a label for a badge, it is used for screen readers and not shown on the screen.", "_XPBoDw.comment": "Select option placeholder", + "_XQ3pyc.comment": "Validation error shown when the edited action code is not valid JSON", "_XQ4OCV.comment": "Time zone value ", "_XR4Sd/.comment": "Chatbot user feedback like button title", "_XR5izH.comment": "Label text to connected status", @@ -3974,6 +3976,7 @@ "_avaDe+.comment": "Title for the MCP server workflows section", "_ayaW+i.comment": "Label for the MCP server workflows field", "_az+QCK.comment": "Logic app name validation message text", + "_b/Zr/q.comment": "Label shown while saving the edited action code", "_b0wO2+.comment": "Stateless workflow description", "_b2aL+f.comment": "Text indicating a menu button to pin an action to the side panel", "_b6G9bq.comment": "Label for description of custom encodeUriComponent Function", @@ -4725,6 +4728,7 @@ "_p+zBbS.comment": "Hint shown after 10 seconds when loading Foundry agents takes longer than expected.", "_p/0r2N.comment": "Required string parameter to be used as key for formDataValue function", "_p/Pfr/.comment": "Message indicating that the user does not have write permissions for the role", + "_p/Vu7Z.comment": "Button to discard edits made to the action code", "_p0BE2D.comment": "Button text to trigger clone in the create workflow panel", "_p1IEXb.comment": "Label for button to open dynamic content token picker", "_p2eSD1.comment": "Button text for opening panel for editing workflows", @@ -4976,6 +4980,7 @@ "_ti2c1D.comment": "Button text for moving to the next tab with action count", "_ti5TEd.comment": "Text for cancel button", "_tjQdhq.comment": "Solution type of the template", + "_tjwYOf.comment": "Button to save the edited action code", "_tkkN++.comment": "Description for template profile tab", "_toHITB.comment": "BizTalk Migration category", "_toWTrl.comment": "Search from file list", @@ -5353,6 +5358,7 @@ "avaDe+": "MCP API key", "ayaW+i": "Workflows", "az+QCK": "Logic app name must start with a letter and can only contain letters, digits, \"_\" and \"-\".", + "b/Zr/q": "Saving…", "b0wO2+": "Optimized for low latency, ideal for request-response and processing IoT events.", "b2aL+f": "Pin action", "b6G9bq": "URL encodes the input string", @@ -6104,6 +6110,7 @@ "p+zBbS": "This may take a moment for new connections.", "p/0r2N": "Required. The key name of the form data value to return.", "p/Pfr/": "Missing role write permissions", + "p/Vu7Z": "Discard", "p0BE2D": "Clone", "p1IEXb": "Enter the data from previous step. You can also add data by typing the '/' character.", "p2eSD1": "Edit", @@ -6355,6 +6362,7 @@ "ti2c1D": "Next ({count} selected)", "ti5TEd": "Cancel", "tjQdhq": "Type", + "tjwYOf": "Save", "tkkN++": "Add details to help template users evaluate this template. The profile includes the information shown to users and settings that control how the template is filtered and displayed.", "toHITB": "BizTalk Migration", "toWTrl": "Search", diff --git a/libs/designer-ui/src/lib/peek/editableCodeView.tsx b/libs/designer-ui/src/lib/peek/editableCodeView.tsx new file mode 100644 index 00000000000..1ce9b90c5e7 --- /dev/null +++ b/libs/designer-ui/src/lib/peek/editableCodeView.tsx @@ -0,0 +1,112 @@ +import { MonacoEditor } from '../editor/monaco'; +import { EditorLanguage } from '@microsoft/logic-apps-shared'; +import { Button, Spinner, Text, makeStyles, tokens } from '@fluentui/react-components'; +import { usePeekStyles } from './styles'; + +const useEditableCodeViewStyles = makeStyles({ + container: { + display: 'flex', + flexDirection: 'column', + height: '100%', + gap: '8px', + }, + toolbar: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + spacer: { + flexGrow: 1, + }, + error: { + color: tokens.colorPaletteRedForeground1, + }, + editorWrapper: { + flexGrow: 1, + minHeight: 0, + }, +}); + +export interface EditableCodeViewProps { + /** The current text shown in the editor (controlled). */ + value: string; + /** Called on every edit with the new editor contents. */ + onChange: (value: string) => void; + /** Called when the user clicks Save. */ + onSave: () => void; + /** Called when the user clicks Discard. */ + onDiscard: () => void; + /** Whether the editor contents differ from the last applied value. */ + isDirty?: boolean; + /** Whether a save is currently in progress. */ + isSaving?: boolean; + /** Validation/error message to surface above the editor. */ + errorMessage?: string; + /** Whether editing is disabled (renders a read-only editor). */ + readOnly?: boolean; + labels: { + save: string; + saving: string; + discard: string; + }; +} + +export function EditableCodeView({ + value, + onChange, + onSave, + onDiscard, + isDirty = false, + isSaving = false, + errorMessage, + readOnly = false, + labels, +}: EditableCodeViewProps): JSX.Element { + const peekStyles = usePeekStyles(); + const styles = useEditableCodeViewStyles(); + + const hasError = !!errorMessage; + const saveDisabled = readOnly || !isDirty || isSaving || hasError; + const discardDisabled = readOnly || !isDirty || isSaving; + + return ( +
+
+ {errorMessage ? ( + + {errorMessage} + + ) : null} +
+ {isSaving ? : null} + + +
+
+ onChange(e.value ?? '')} + fontSize={13} + readOnly={readOnly} + folding={false} + lineNumbers={'on'} + language={EditorLanguage.json} + label="editable-code-view" + wordWrap={'on'} + monacoContainerStyle={{ height: '100%' }} + /> +
+
+ ); +} diff --git a/libs/designer-ui/src/lib/peek/index.tsx b/libs/designer-ui/src/lib/peek/index.tsx index 78200c1dc4c..401163f4c41 100644 --- a/libs/designer-ui/src/lib/peek/index.tsx +++ b/libs/designer-ui/src/lib/peek/index.tsx @@ -2,6 +2,8 @@ import { MonacoEditor } from '../editor/monaco'; import { EditorLanguage } from '@microsoft/logic-apps-shared'; import { usePeekStyles } from './styles'; +export * from './editableCodeView'; + export interface PeekProps { input: string; } diff --git a/libs/designer/src/lib/core/actions/bjsworkflow/operationdeserializer.ts b/libs/designer/src/lib/core/actions/bjsworkflow/operationdeserializer.ts index 4f0d2700716..740fc053dbf 100644 --- a/libs/designer/src/lib/core/actions/bjsworkflow/operationdeserializer.ts +++ b/libs/designer/src/lib/core/actions/bjsworkflow/operationdeserializer.ts @@ -505,7 +505,7 @@ const processChildGraphAndItsInputs = ( return nodesData; }; -const updateTokenMetadataInParameters = ( +export const updateTokenMetadataInParameters = ( nodes: NodeDataWithOperationMetadata[], operations: Operations, workflowParameters: Record, @@ -583,7 +583,7 @@ const updateTokenMetadataInParameters = ( } }; -const initializeOutputTokensForOperations = ( +export const initializeOutputTokensForOperations = ( allNodesData: NodeDataWithOperationMetadata[], operations: Operations, graph: WorkflowNode, @@ -645,7 +645,7 @@ const initializeOutputTokensForOperations = ( return result; }; -const initializeVariables = ( +export const initializeVariables = ( operations: Operations, allNodesData: NodeDataWithOperationMetadata[] ): Record => { @@ -670,7 +670,7 @@ const initializeVariables = ( return declarations; }; -const initializeRepetitionInfos = async ( +export const initializeRepetitionInfos = async ( triggerNodeId: string, allOperations: Operations, nodesData: NodeDataWithOperationMetadata[], diff --git a/libs/designer/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts b/libs/designer/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts new file mode 100644 index 00000000000..460f453f66d --- /dev/null +++ b/libs/designer/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts @@ -0,0 +1,125 @@ +import { getTriggerNodeId, type RootState } from '../..'; +import type { LogicAppsV2 } from '@microsoft/logic-apps-shared'; +import { OperationManifestService, getRecordEntry } from '@microsoft/logic-apps-shared'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { setIsPanelLoading } from '../../state/panel/panelSlice'; +import { initializeNodes } from '../../state/operation/operationMetadataSlice'; +import { initializeTokensAndVariables } from '../../state/tokens/tokensSlice'; +import { replaceOperationDefinition } from '../../state/workflow/workflowSlice'; +import { isManagedMcpOperation } from '../../state/workflow/helper'; +import { isTriggerNode } from '../../utils/graph'; +import Constants from '../../../common/constants'; +import { initializeConnectorOperationDetails } from './agent'; +import { updateAllUpstreamNodes } from './initialize'; +import { + initializeDynamicDataInNodes, + initializeOperationDetailsForManagedMcpServer, + initializeOperationDetailsForManifest, + initializeOutputTokensForOperations, + initializeRepetitionInfos, + initializeVariables, + updateTokenMetadataInParameters, + type NodeDataWithOperationMetadata, +} from './operationdeserializer'; +import { initializeOperationDetailsForSwagger } from '../../utils/swagger/operation'; + +export interface UpdateNodeFromCodeViewPayload { + nodeId: string; + serializedOperation: LogicAppsV2.OperationDefinition; +} + +/** + * Applies an edited single-operation definition (from the per-action Code view) back into + * the designer state. Only the edited node is re-initialized; other nodes (and their + * unsaved edits) are left untouched. + * + * Note: structural/graph changes (renaming the action key, changing runAfter / nesting, + * adding or removing child actions) are NOT applied here -- those still require the + * whole-workflow code view. + */ +export const updateNodeFromCodeView = createAsyncThunk( + 'updateNodeFromCodeView', + async (payload: UpdateNodeFromCodeViewPayload, { dispatch, getState }): Promise => { + const { nodeId, serializedOperation } = payload; + const state = getState() as RootState; + + if (!getRecordEntry(state.workflow.operations, nodeId)) { + return; + } + + dispatch(setIsPanelLoading(true)); + try { + // Persist the new definition so serialization/runAfter fallbacks stay in sync. + dispatch(replaceOperationDefinition({ nodeId, operationDefinition: serializedOperation })); + + const updatedState = getState() as RootState; + const { operations, nodesMetadata, workflowKind, graph } = updatedState.workflow; + const references = updatedState.connections.connectionReferences; + const workflowParameters = updatedState.workflowParameters.definitions; + const isTrigger = isTriggerNode(nodeId, nodesMetadata); + const triggerNodeId = getTriggerNodeId(updatedState.workflow); + + const operationManifestService = OperationManifestService(); + let nodeData: NodeDataWithOperationMetadata[] | undefined; + if (isManagedMcpOperation(serializedOperation)) { + nodeData = await initializeOperationDetailsForManagedMcpServer(nodeId, serializedOperation, references, workflowKind, dispatch); + } else if (serializedOperation.type === Constants.NODE.TYPE.CONNECTOR) { + nodeData = await initializeConnectorOperationDetails( + nodeId, + serializedOperation as LogicAppsV2.ConnectorAction, + workflowKind, + dispatch + ); + } else if (operationManifestService.isSupported(serializedOperation.type, serializedOperation.kind)) { + nodeData = await initializeOperationDetailsForManifest( + nodeId, + serializedOperation, + {} /* customCode */, + isTrigger, + workflowKind, + dispatch + ); + } else { + nodeData = await initializeOperationDetailsForSwagger(nodeId, serializedOperation, references, isTrigger, workflowKind, dispatch); + } + + if (!nodeData || nodeData.length === 0) { + return; + } + + const repetitionInfos = await initializeRepetitionInfos(triggerNodeId, operations, nodeData, nodesMetadata); + updateTokenMetadataInParameters(nodeData, operations, workflowParameters, nodesMetadata, triggerNodeId, repetitionInfos); + + dispatch( + initializeNodes({ + nodes: nodeData.map((data) => { + const { id, nodeInputs, nodeOutputs, nodeDependencies, settings, operationMetadata, staticResult, supportedChannels } = data; + return { + id, + nodeInputs, + nodeOutputs, + nodeDependencies, + settings, + operationMetadata, + staticResult, + supportedChannels, + actionMetadata: getRecordEntry(nodesMetadata, id)?.actionMetadata, + repetitionInfo: getRecordEntry(repetitionInfos, id), + }; + }), + clearExisting: false, + }) + ); + + const singleOperation = { [nodeId]: serializedOperation }; + const outputTokens = graph ? initializeOutputTokensForOperations(nodeData, singleOperation, graph, nodesMetadata) : {}; + const variables = initializeVariables(singleOperation, nodeData); + dispatch(initializeTokensAndVariables({ outputTokens, variables })); + + await initializeDynamicDataInNodes(getState as () => RootState, dispatch, [nodeId]); + updateAllUpstreamNodes(getState() as RootState, dispatch); + } finally { + dispatch(setIsPanelLoading(false)); + } + } +); diff --git a/libs/designer/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts b/libs/designer/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts index 067cfc66e94..ccfa24698ad 100644 --- a/libs/designer/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts +++ b/libs/designer/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts @@ -207,3 +207,59 @@ describe('workflowSlice - setRepetitionRunData', () => { expect(updatedRunData.error).toBeUndefined(); }); }); + +describe('workflowSlice - replaceOperationDefinition', () => { + const nodeId = 'Compose'; + + const buildState = (operations: Record): WorkflowState => + ({ + graph: null, + operations, + nodesMetadata: {}, + collapsedGraphIds: {}, + collapsedActionIds: {}, + idReplacements: {}, + newlyAddedOperations: {}, + runInstance: null, + isDirty: false, + workflowKind: undefined, + originalDefinition: {} as LogicAppsV2.WorkflowDefinition, + hostData: { errorMessages: {} }, + agentsGraph: {}, + timelineRepetitionIndex: 0, + timelineRepetitionArray: [], + flowErrors: {}, + }) as WorkflowState; + + test('replaces the operation definition for an existing node', () => { + const initialState = buildState({ + [nodeId]: { type: 'Compose', inputs: 'old-value' }, + }); + + const newDefinition = { type: 'Compose', inputs: 'new-value', runAfter: {} } as unknown as LogicAppsV2.OperationDefinition; + const newState = workflowSlice.reducer( + initialState, + workflowSlice.actions.replaceOperationDefinition({ nodeId, operationDefinition: newDefinition }) + ); + + expect((newState.operations[nodeId] as any).inputs).toBe('new-value'); + expect((newState.operations[nodeId] as any).runAfter).toEqual({}); + }); + + test('does nothing when the node does not exist', () => { + const initialState = buildState({ + [nodeId]: { type: 'Compose', inputs: 'old-value' }, + }); + + const newState = workflowSlice.reducer( + initialState, + workflowSlice.actions.replaceOperationDefinition({ + nodeId: 'DoesNotExist', + operationDefinition: { type: 'Compose', inputs: 'x' } as unknown as LogicAppsV2.OperationDefinition, + }) + ); + + expect(newState.operations['DoesNotExist']).toBeUndefined(); + expect((newState.operations[nodeId] as any).inputs).toBe('old-value'); + }); +}); diff --git a/libs/designer/src/lib/core/state/workflow/workflowSlice.ts b/libs/designer/src/lib/core/state/workflow/workflowSlice.ts index bd20b17160c..4873a405efe 100644 --- a/libs/designer/src/lib/core/state/workflow/workflowSlice.ts +++ b/libs/designer/src/lib/core/state/workflow/workflowSlice.ts @@ -105,6 +105,16 @@ export const workflowSlice = createSlice({ } nodeOperation.description = description; }, + replaceOperationDefinition: ( + state: WorkflowState, + action: PayloadAction<{ nodeId: string; operationDefinition: LogicAppsV2.OperationDefinition }> + ) => { + const { nodeId, operationDefinition } = action.payload; + if (!getRecordEntry(state.operations, nodeId)) { + return; + } + state.operations[nodeId] = operationDefinition; + }, addNode: (state: WorkflowState, action: PayloadAction) => { if (!state.graph) { return; // log exception @@ -884,6 +894,7 @@ export const workflowSlice = createSlice({ addImplicitForeachNode, pasteScopeNode, setNodeDescription, + replaceOperationDefinition, updateRunAfter, removeRunAfter, addRunAfter, @@ -915,6 +926,7 @@ export const { deleteMcpServer, updateNodeSizes, setNodeDescription, + replaceOperationDefinition, toggleCollapsedGraphId, addSwitchCase, addAgentTool, diff --git a/libs/designer/src/lib/ui/panel/nodeDetailsPanel/tabs/codeViewTab.tsx b/libs/designer/src/lib/ui/panel/nodeDetailsPanel/tabs/codeViewTab.tsx index ddc3ecd1788..1c53dae6a07 100644 --- a/libs/designer/src/lib/ui/panel/nodeDetailsPanel/tabs/codeViewTab.tsx +++ b/libs/designer/src/lib/ui/panel/nodeDetailsPanel/tabs/codeViewTab.tsx @@ -1,18 +1,27 @@ import constants from '../../../../common/constants'; import { serializeOperation } from '../../../../core/actions/bjsworkflow/serializer'; +import { updateNodeFromCodeView } from '../../../../core/actions/bjsworkflow/updateNodeFromCodeView'; +import { useReadOnly } from '../../../../core/state/designerOptions/designerOptionsSelectors'; import { useActionMetadata } from '../../../../core/state/workflow/workflowSelectors'; -import type { RootState } from '../../../../core/store'; +import type { AppDispatch, RootState } from '../../../../core/store'; import type { PanelTabFn, PanelTabProps } from '@microsoft/designer-ui'; -import { Peek } from '@microsoft/designer-ui'; +import { EditableCodeView, Peek } from '@microsoft/designer-ui'; import { isNullOrEmpty } from '@microsoft/logic-apps-shared'; import { useQuery } from '@tanstack/react-query'; -import { useSelector } from 'react-redux'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useDispatch, useStore } from 'react-redux'; export const CodeViewTab: React.FC = (props) => { const { nodeId } = props; + const intl = useIntl(); + const dispatch = useDispatch(); + const readOnly = useReadOnly(); const nodeMetaData = useActionMetadata(nodeId) as any; - const rootState = useSelector((state: RootState) => state); - const queryData = useQuery(['serialization', { nodeId }], () => serializeOperation(rootState, nodeId), { + // Read the store directly so that each (re)serialization—including the refetch after a + // save—reflects the latest state rather than a snapshot captured at render time. + const store = useStore(); + const queryData = useQuery(['serialization', { nodeId }], () => serializeOperation(store.getState(), nodeId), { retry: false, cacheTime: 0, refetchOnMount: false, @@ -20,11 +29,97 @@ export const CodeViewTab: React.FC = (props) => { refetchOnReconnect: false, }); - const content = queryData.isLoading - ? 'Loading ...' - : JSON.stringify(isNullOrEmpty(queryData.data) ? { inputs: nodeMetaData?.inputs ?? {} } : queryData.data, null, 2); + const serializedContent = useMemo( + () => JSON.stringify(isNullOrEmpty(queryData.data) ? { inputs: nodeMetaData?.inputs ?? {} } : queryData.data, null, 2), + [queryData.data, nodeMetaData?.inputs] + ); - return ; + const [editedContent, setEditedContent] = useState(serializedContent); + const [isSaving, setIsSaving] = useState(false); + + // Re-sync the editor when the underlying serialization changes (e.g. node switch, external edits). + useEffect(() => { + setEditedContent(serializedContent); + }, [serializedContent]); + + const isDirty = editedContent !== serializedContent; + + const validationError = useMemo(() => { + if (!isDirty) { + return undefined; + } + try { + JSON.parse(editedContent); + return undefined; + } catch { + return intl.formatMessage({ + defaultMessage: 'Invalid JSON. Fix the errors before saving.', + id: 'eaI2mF', + description: 'Validation error shown when the edited action code is not valid JSON', + }); + } + }, [editedContent, isDirty, intl]); + + const labels = useMemo( + () => ({ + save: intl.formatMessage({ defaultMessage: 'Save', id: 'SUqh1Y', description: 'Button to save the edited action code' }), + saving: intl.formatMessage({ + defaultMessage: 'Saving…', + id: 'OMW+Rg', + description: 'Label shown while saving the edited action code', + }), + discard: intl.formatMessage({ + defaultMessage: 'Discard', + id: 'kWpQvf', + description: 'Button to discard edits made to the action code', + }), + }), + [intl] + ); + + const handleDiscard = useCallback(() => { + setEditedContent(serializedContent); + }, [serializedContent]); + + const handleSave = useCallback(async () => { + if (validationError) { + return; + } + let parsed: any; + try { + parsed = JSON.parse(editedContent); + } catch { + return; + } + setIsSaving(true); + try { + await dispatch(updateNodeFromCodeView({ nodeId, serializedOperation: parsed })).unwrap(); + await queryData.refetch(); + } finally { + setIsSaving(false); + } + }, [dispatch, editedContent, nodeId, queryData, validationError]); + + if (queryData.isLoading) { + return ; + } + + if (readOnly) { + return ; + } + + return ( + + ); }; export const codeViewTab: PanelTabFn = (intl, props) => ({ From f9f1a5eee85efc3a90ed93da4dbcab02f2af36a1 Mon Sep 17 00:00:00 2001 From: Riley Evans Date: Tue, 30 Jun 2026 12:22:44 -0500 Subject: [PATCH 2/6] refactor(designer-v2): move editable code view from designer to designer-v2 Reverts the per-action editable code view from the designer lib and re-implements it in designer-v2 (shared EditableCodeView in designer-ui is unchanged). Adds the replaceOperationDefinition reducer, exports the operationdeserializer helpers, the updateNodeFromCodeView thunk, and the stateful codeViewTab container with JSON validation + save/discard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bjsworkflow/operationdeserializer.ts | 8 +- .../bjsworkflow/updateNodeFromCodeView.ts | 0 .../workflow/__test__/workflowSlice.spec.ts | 54 +++++++++ .../lib/core/state/workflow/workflowSlice.ts | 12 ++ .../nodeDetailsPanel/tabs/codeViewTab.tsx | 113 ++++++++++++++++-- .../bjsworkflow/operationdeserializer.ts | 8 +- .../workflow/__test__/workflowSlice.spec.ts | 56 --------- .../lib/core/state/workflow/workflowSlice.ts | 12 -- .../nodeDetailsPanel/tabs/codeViewTab.tsx | 113 ++---------------- 9 files changed, 187 insertions(+), 189 deletions(-) rename libs/{designer => designer-v2}/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts (100%) diff --git a/libs/designer-v2/src/lib/core/actions/bjsworkflow/operationdeserializer.ts b/libs/designer-v2/src/lib/core/actions/bjsworkflow/operationdeserializer.ts index ea74ac5cdb9..69a33a520ee 100644 --- a/libs/designer-v2/src/lib/core/actions/bjsworkflow/operationdeserializer.ts +++ b/libs/designer-v2/src/lib/core/actions/bjsworkflow/operationdeserializer.ts @@ -503,7 +503,7 @@ const processChildGraphAndItsInputs = ( return nodesData; }; -const updateTokenMetadataInParameters = ( +export const updateTokenMetadataInParameters = ( nodes: NodeDataWithOperationMetadata[], operations: Operations, workflowParameters: Record, @@ -581,7 +581,7 @@ const updateTokenMetadataInParameters = ( } }; -const initializeOutputTokensForOperations = ( +export const initializeOutputTokensForOperations = ( allNodesData: NodeDataWithOperationMetadata[], operations: Operations, graph: WorkflowNode, @@ -642,7 +642,7 @@ const initializeOutputTokensForOperations = ( return result; }; -const initializeVariables = ( +export const initializeVariables = ( operations: Operations, allNodesData: NodeDataWithOperationMetadata[] ): Record => { @@ -667,7 +667,7 @@ const initializeVariables = ( return declarations; }; -const initializeRepetitionInfos = async ( +export const initializeRepetitionInfos = async ( triggerNodeId: string, allOperations: Operations, nodesData: NodeDataWithOperationMetadata[], diff --git a/libs/designer/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts b/libs/designer-v2/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts similarity index 100% rename from libs/designer/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts rename to libs/designer-v2/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts diff --git a/libs/designer-v2/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts b/libs/designer-v2/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts index cd8a25dc473..c8d2e8f836c 100644 --- a/libs/designer-v2/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts +++ b/libs/designer-v2/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts @@ -208,3 +208,57 @@ describe('workflowSlice - setRepetitionRunData', () => { expect(updatedRunData.error).toBeUndefined(); }); }); + +describe('workflowSlice - replaceOperationDefinition', () => { + const nodeId = 'testNode'; + + const buildState = (operations: Record): WorkflowState => + ({ + graph: null, + operations, + nodesMetadata: {}, + collapsedGraphIds: {}, + collapsedActionIds: {}, + idReplacements: {}, + newlyAddedOperations: {}, + runInstance: null, + isDirty: false, + workflowKind: undefined, + originalDefinition: {} as LogicAppsV2.WorkflowDefinition, + hostData: { errorMessages: {} }, + agentsGraph: {}, + timelineRepetitionIndex: 0, + changeCount: 0, + timelineRepetitionArray: [], + flowErrors: {}, + }) as WorkflowState; + + test('should replace the operation definition for an existing node', () => { + const initialState = buildState({ + [nodeId]: { type: 'InitializeVariable', inputs: { variables: [{ name: 'old', type: 'String', value: 'a' }] } }, + }); + + const operationDefinition = { + type: 'InitializeVariable', + inputs: { variables: [{ name: 'renamed', type: 'String', value: 'b' }] }, + } as unknown as LogicAppsV2.OperationDefinition; + + const newState = workflowSlice.reducer(initialState, workflowSlice.actions.replaceOperationDefinition({ nodeId, operationDefinition })); + + expect(newState.operations[nodeId]).toEqual(operationDefinition); + expect(newState.isDirty).toBe(true); + }); + + test('should no-op when the node does not exist', () => { + const initialState = buildState({}); + + const operationDefinition = { type: 'Compose', inputs: {} } as unknown as LogicAppsV2.OperationDefinition; + + const newState = workflowSlice.reducer( + initialState, + workflowSlice.actions.replaceOperationDefinition({ nodeId: 'missing', operationDefinition }) + ); + + expect(newState.operations['missing']).toBeUndefined(); + }); +}); diff --git a/libs/designer-v2/src/lib/core/state/workflow/workflowSlice.ts b/libs/designer-v2/src/lib/core/state/workflow/workflowSlice.ts index 0ead61c3c70..a6db4570e3a 100644 --- a/libs/designer-v2/src/lib/core/state/workflow/workflowSlice.ts +++ b/libs/designer-v2/src/lib/core/state/workflow/workflowSlice.ts @@ -114,6 +114,16 @@ export const workflowSlice = createSlice({ } nodeOperation.description = description; }, + replaceOperationDefinition: ( + state: WorkflowState, + action: PayloadAction<{ nodeId: string; operationDefinition: LogicAppsV2.OperationDefinition }> + ) => { + const { nodeId, operationDefinition } = action.payload; + if (!getRecordEntry(state.operations, nodeId)) { + return; + } + state.operations[nodeId] = operationDefinition; + }, addNode: (state: WorkflowState, action: PayloadAction) => { if (!state.graph) { return; // log exception @@ -1059,6 +1069,7 @@ export const workflowSlice = createSlice({ wrapNodesInScope, pasteScopeNode, setNodeDescription, + replaceOperationDefinition, updateRunAfter, removeRunAfter, addRunAfter, @@ -1092,6 +1103,7 @@ export const { deleteMcpServer, updateNodeSizes, setNodeDescription, + replaceOperationDefinition, toggleCollapsedGraphId, addSwitchCase, addAgentTool, diff --git a/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/tabs/codeViewTab.tsx b/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/tabs/codeViewTab.tsx index ea87c9285d6..8229ccc0631 100644 --- a/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/tabs/codeViewTab.tsx +++ b/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/tabs/codeViewTab.tsx @@ -1,18 +1,27 @@ import constants from '../../../../common/constants'; import { serializeOperation } from '../../../../core/actions/bjsworkflow/serializer'; +import { updateNodeFromCodeView } from '../../../../core/actions/bjsworkflow/updateNodeFromCodeView'; +import { useReadOnly } from '../../../../core/state/designerOptions/designerOptionsSelectors'; import { useActionMetadata } from '../../../../core/state/workflow/workflowSelectors'; -import type { RootState } from '../../../../core/store'; +import type { AppDispatch, RootState } from '../../../../core/store'; import type { PanelTabFn, PanelTabProps } from '@microsoft/designer-ui'; -import { Peek } from '@microsoft/designer-ui'; +import { EditableCodeView, Peek } from '@microsoft/designer-ui'; import { isNullOrEmpty } from '@microsoft/logic-apps-shared'; import { useQuery } from '@tanstack/react-query'; -import { useSelector } from 'react-redux'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useDispatch, useStore } from 'react-redux'; export const CodeViewTab: React.FC = (props) => { const { nodeId } = props; + const intl = useIntl(); + const dispatch = useDispatch(); + const readOnly = useReadOnly(); const nodeMetaData = useActionMetadata(nodeId) as any; - const rootState = useSelector((state: RootState) => state); - const queryData = useQuery(['serialization', { nodeId }], () => serializeOperation(rootState, nodeId), { + // Read the store directly so that each (re)serialization—including the refetch after a + // save—reflects the latest state rather than a snapshot captured at render time. + const store = useStore(); + const queryData = useQuery(['serialization', { nodeId }], () => serializeOperation(store.getState(), nodeId), { retry: false, cacheTime: 0, refetchOnMount: false, @@ -20,11 +29,97 @@ export const CodeViewTab: React.FC = (props) => { refetchOnReconnect: false, }); - const content = queryData.isLoading - ? 'Loading ...' - : JSON.stringify(isNullOrEmpty(queryData.data) ? { inputs: nodeMetaData?.inputs ?? {} } : queryData.data, null, 2); + const serializedContent = useMemo( + () => JSON.stringify(isNullOrEmpty(queryData.data) ? { inputs: nodeMetaData?.inputs ?? {} } : queryData.data, null, 2), + [queryData.data, nodeMetaData?.inputs] + ); - return ; + const [editedContent, setEditedContent] = useState(serializedContent); + const [isSaving, setIsSaving] = useState(false); + + // Re-sync the editor when the underlying serialization changes (e.g. node switch, external edits). + useEffect(() => { + setEditedContent(serializedContent); + }, [serializedContent]); + + const isDirty = editedContent !== serializedContent; + + const validationError = useMemo(() => { + if (!isDirty) { + return undefined; + } + try { + JSON.parse(editedContent); + return undefined; + } catch { + return intl.formatMessage({ + defaultMessage: 'Invalid JSON. Fix the errors before saving.', + id: 'eaI2mF', + description: 'Validation error shown when the edited action code is not valid JSON', + }); + } + }, [editedContent, isDirty, intl]); + + const labels = useMemo( + () => ({ + save: intl.formatMessage({ defaultMessage: 'Save', id: 'SUqh1Y', description: 'Button to save the edited action code' }), + saving: intl.formatMessage({ + defaultMessage: 'Saving…', + id: 'OMW+Rg', + description: 'Label shown while saving the edited action code', + }), + discard: intl.formatMessage({ + defaultMessage: 'Discard', + id: 'kWpQvf', + description: 'Button to discard edits made to the action code', + }), + }), + [intl] + ); + + const handleDiscard = useCallback(() => { + setEditedContent(serializedContent); + }, [serializedContent]); + + const handleSave = useCallback(async () => { + if (validationError) { + return; + } + let parsed: any; + try { + parsed = JSON.parse(editedContent); + } catch { + return; + } + setIsSaving(true); + try { + await dispatch(updateNodeFromCodeView({ nodeId, serializedOperation: parsed })).unwrap(); + await queryData.refetch(); + } finally { + setIsSaving(false); + } + }, [dispatch, editedContent, nodeId, queryData, validationError]); + + if (queryData.isLoading) { + return ; + } + + if (readOnly) { + return ; + } + + return ( + + ); }; export const codeViewTab: PanelTabFn = (intl, props) => ({ diff --git a/libs/designer/src/lib/core/actions/bjsworkflow/operationdeserializer.ts b/libs/designer/src/lib/core/actions/bjsworkflow/operationdeserializer.ts index 740fc053dbf..4f0d2700716 100644 --- a/libs/designer/src/lib/core/actions/bjsworkflow/operationdeserializer.ts +++ b/libs/designer/src/lib/core/actions/bjsworkflow/operationdeserializer.ts @@ -505,7 +505,7 @@ const processChildGraphAndItsInputs = ( return nodesData; }; -export const updateTokenMetadataInParameters = ( +const updateTokenMetadataInParameters = ( nodes: NodeDataWithOperationMetadata[], operations: Operations, workflowParameters: Record, @@ -583,7 +583,7 @@ export const updateTokenMetadataInParameters = ( } }; -export const initializeOutputTokensForOperations = ( +const initializeOutputTokensForOperations = ( allNodesData: NodeDataWithOperationMetadata[], operations: Operations, graph: WorkflowNode, @@ -645,7 +645,7 @@ export const initializeOutputTokensForOperations = ( return result; }; -export const initializeVariables = ( +const initializeVariables = ( operations: Operations, allNodesData: NodeDataWithOperationMetadata[] ): Record => { @@ -670,7 +670,7 @@ export const initializeVariables = ( return declarations; }; -export const initializeRepetitionInfos = async ( +const initializeRepetitionInfos = async ( triggerNodeId: string, allOperations: Operations, nodesData: NodeDataWithOperationMetadata[], diff --git a/libs/designer/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts b/libs/designer/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts index ccfa24698ad..067cfc66e94 100644 --- a/libs/designer/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts +++ b/libs/designer/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts @@ -207,59 +207,3 @@ describe('workflowSlice - setRepetitionRunData', () => { expect(updatedRunData.error).toBeUndefined(); }); }); - -describe('workflowSlice - replaceOperationDefinition', () => { - const nodeId = 'Compose'; - - const buildState = (operations: Record): WorkflowState => - ({ - graph: null, - operations, - nodesMetadata: {}, - collapsedGraphIds: {}, - collapsedActionIds: {}, - idReplacements: {}, - newlyAddedOperations: {}, - runInstance: null, - isDirty: false, - workflowKind: undefined, - originalDefinition: {} as LogicAppsV2.WorkflowDefinition, - hostData: { errorMessages: {} }, - agentsGraph: {}, - timelineRepetitionIndex: 0, - timelineRepetitionArray: [], - flowErrors: {}, - }) as WorkflowState; - - test('replaces the operation definition for an existing node', () => { - const initialState = buildState({ - [nodeId]: { type: 'Compose', inputs: 'old-value' }, - }); - - const newDefinition = { type: 'Compose', inputs: 'new-value', runAfter: {} } as unknown as LogicAppsV2.OperationDefinition; - const newState = workflowSlice.reducer( - initialState, - workflowSlice.actions.replaceOperationDefinition({ nodeId, operationDefinition: newDefinition }) - ); - - expect((newState.operations[nodeId] as any).inputs).toBe('new-value'); - expect((newState.operations[nodeId] as any).runAfter).toEqual({}); - }); - - test('does nothing when the node does not exist', () => { - const initialState = buildState({ - [nodeId]: { type: 'Compose', inputs: 'old-value' }, - }); - - const newState = workflowSlice.reducer( - initialState, - workflowSlice.actions.replaceOperationDefinition({ - nodeId: 'DoesNotExist', - operationDefinition: { type: 'Compose', inputs: 'x' } as unknown as LogicAppsV2.OperationDefinition, - }) - ); - - expect(newState.operations['DoesNotExist']).toBeUndefined(); - expect((newState.operations[nodeId] as any).inputs).toBe('old-value'); - }); -}); diff --git a/libs/designer/src/lib/core/state/workflow/workflowSlice.ts b/libs/designer/src/lib/core/state/workflow/workflowSlice.ts index 4873a405efe..bd20b17160c 100644 --- a/libs/designer/src/lib/core/state/workflow/workflowSlice.ts +++ b/libs/designer/src/lib/core/state/workflow/workflowSlice.ts @@ -105,16 +105,6 @@ export const workflowSlice = createSlice({ } nodeOperation.description = description; }, - replaceOperationDefinition: ( - state: WorkflowState, - action: PayloadAction<{ nodeId: string; operationDefinition: LogicAppsV2.OperationDefinition }> - ) => { - const { nodeId, operationDefinition } = action.payload; - if (!getRecordEntry(state.operations, nodeId)) { - return; - } - state.operations[nodeId] = operationDefinition; - }, addNode: (state: WorkflowState, action: PayloadAction) => { if (!state.graph) { return; // log exception @@ -894,7 +884,6 @@ export const workflowSlice = createSlice({ addImplicitForeachNode, pasteScopeNode, setNodeDescription, - replaceOperationDefinition, updateRunAfter, removeRunAfter, addRunAfter, @@ -926,7 +915,6 @@ export const { deleteMcpServer, updateNodeSizes, setNodeDescription, - replaceOperationDefinition, toggleCollapsedGraphId, addSwitchCase, addAgentTool, diff --git a/libs/designer/src/lib/ui/panel/nodeDetailsPanel/tabs/codeViewTab.tsx b/libs/designer/src/lib/ui/panel/nodeDetailsPanel/tabs/codeViewTab.tsx index 1c53dae6a07..ddc3ecd1788 100644 --- a/libs/designer/src/lib/ui/panel/nodeDetailsPanel/tabs/codeViewTab.tsx +++ b/libs/designer/src/lib/ui/panel/nodeDetailsPanel/tabs/codeViewTab.tsx @@ -1,27 +1,18 @@ import constants from '../../../../common/constants'; import { serializeOperation } from '../../../../core/actions/bjsworkflow/serializer'; -import { updateNodeFromCodeView } from '../../../../core/actions/bjsworkflow/updateNodeFromCodeView'; -import { useReadOnly } from '../../../../core/state/designerOptions/designerOptionsSelectors'; import { useActionMetadata } from '../../../../core/state/workflow/workflowSelectors'; -import type { AppDispatch, RootState } from '../../../../core/store'; +import type { RootState } from '../../../../core/store'; import type { PanelTabFn, PanelTabProps } from '@microsoft/designer-ui'; -import { EditableCodeView, Peek } from '@microsoft/designer-ui'; +import { Peek } from '@microsoft/designer-ui'; import { isNullOrEmpty } from '@microsoft/logic-apps-shared'; import { useQuery } from '@tanstack/react-query'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useIntl } from 'react-intl'; -import { useDispatch, useStore } from 'react-redux'; +import { useSelector } from 'react-redux'; export const CodeViewTab: React.FC = (props) => { const { nodeId } = props; - const intl = useIntl(); - const dispatch = useDispatch(); - const readOnly = useReadOnly(); const nodeMetaData = useActionMetadata(nodeId) as any; - // Read the store directly so that each (re)serialization—including the refetch after a - // save—reflects the latest state rather than a snapshot captured at render time. - const store = useStore(); - const queryData = useQuery(['serialization', { nodeId }], () => serializeOperation(store.getState(), nodeId), { + const rootState = useSelector((state: RootState) => state); + const queryData = useQuery(['serialization', { nodeId }], () => serializeOperation(rootState, nodeId), { retry: false, cacheTime: 0, refetchOnMount: false, @@ -29,97 +20,11 @@ export const CodeViewTab: React.FC = (props) => { refetchOnReconnect: false, }); - const serializedContent = useMemo( - () => JSON.stringify(isNullOrEmpty(queryData.data) ? { inputs: nodeMetaData?.inputs ?? {} } : queryData.data, null, 2), - [queryData.data, nodeMetaData?.inputs] - ); + const content = queryData.isLoading + ? 'Loading ...' + : JSON.stringify(isNullOrEmpty(queryData.data) ? { inputs: nodeMetaData?.inputs ?? {} } : queryData.data, null, 2); - const [editedContent, setEditedContent] = useState(serializedContent); - const [isSaving, setIsSaving] = useState(false); - - // Re-sync the editor when the underlying serialization changes (e.g. node switch, external edits). - useEffect(() => { - setEditedContent(serializedContent); - }, [serializedContent]); - - const isDirty = editedContent !== serializedContent; - - const validationError = useMemo(() => { - if (!isDirty) { - return undefined; - } - try { - JSON.parse(editedContent); - return undefined; - } catch { - return intl.formatMessage({ - defaultMessage: 'Invalid JSON. Fix the errors before saving.', - id: 'eaI2mF', - description: 'Validation error shown when the edited action code is not valid JSON', - }); - } - }, [editedContent, isDirty, intl]); - - const labels = useMemo( - () => ({ - save: intl.formatMessage({ defaultMessage: 'Save', id: 'SUqh1Y', description: 'Button to save the edited action code' }), - saving: intl.formatMessage({ - defaultMessage: 'Saving…', - id: 'OMW+Rg', - description: 'Label shown while saving the edited action code', - }), - discard: intl.formatMessage({ - defaultMessage: 'Discard', - id: 'kWpQvf', - description: 'Button to discard edits made to the action code', - }), - }), - [intl] - ); - - const handleDiscard = useCallback(() => { - setEditedContent(serializedContent); - }, [serializedContent]); - - const handleSave = useCallback(async () => { - if (validationError) { - return; - } - let parsed: any; - try { - parsed = JSON.parse(editedContent); - } catch { - return; - } - setIsSaving(true); - try { - await dispatch(updateNodeFromCodeView({ nodeId, serializedOperation: parsed })).unwrap(); - await queryData.refetch(); - } finally { - setIsSaving(false); - } - }, [dispatch, editedContent, nodeId, queryData, validationError]); - - if (queryData.isLoading) { - return ; - } - - if (readOnly) { - return ; - } - - return ( - - ); + return ; }; export const codeViewTab: PanelTabFn = (intl, props) => ({ From 252c24ade31a3153ccd7f5b5e33d1218fc6ebb5b Mon Sep 17 00:00:00 2001 From: Riley Evans Date: Tue, 30 Jun 2026 13:02:41 -0500 Subject: [PATCH 3/6] fix(designer-v2): reconcile graph edges when code-view edit changes runAfter Editing an action's runAfter inline via the Code preview tab now updates the node's position in the graph. replaceOperationDefinition diffs the old vs new runAfter parents and reconciles the scope's BUTTON/HEADING edges (mirroring the BJS deserializer: parent edges for run-after deps, trigger edge for top-level roots, scope-header edge for scoped roots), then re-syncs isRoot metadata via applyIsRootNode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Localize/lang/strings.json | 16 +-- .../bjsworkflow/updateNodeFromCodeView.ts | 7 +- .../workflow/__test__/workflowSlice.spec.ts | 99 +++++++++++++++++++ .../lib/core/state/workflow/workflowSlice.ts | 95 +++++++++++++++++- 4 files changed, 203 insertions(+), 14 deletions(-) diff --git a/Localize/lang/strings.json b/Localize/lang/strings.json index 4a15f18d96a..6c4a066f445 100644 --- a/Localize/lang/strings.json +++ b/Localize/lang/strings.json @@ -1334,6 +1334,7 @@ "OFXJe0": "Tools", "OH9xlX": "10", "OIOexo": "On these days", + "OMW+Rg": "Saving…", "OMbXig": "Name", "OMr20n": "Sandbox Configuration (Optional)", "OMuMCI": "Workflow properties", @@ -1547,6 +1548,7 @@ "SToblZ": "Configure parameters for this node", "SUaXux": "These are the connection services this template depends on. When users deploy a workflow using this template, they’ll be prompted to create connections to the services it uses.", "SUo94t": "Add files", + "SUqh1Y": "Save", "SXb47U": "{minutes}m", "SY04wn": "Required. The name of the action with a form-data or form-encoded response.", "SY9ptd": "Each action has parameters that accept input. Check the default input sources and make any necessary changes to meet your scenario.", @@ -1819,7 +1821,6 @@ "XOAcjQ": "(UTC+03:00) Nairobi", "XOzn/3": "Connection name", "XPBoDw": "Select an option", - "XQ3pyc": "Invalid JSON. Fix the errors before saving.", "XQ4OCV": "(UTC+03:00) Baghdad", "XR4Sd/": "Like", "XR5izH": "Connected", @@ -3298,6 +3299,7 @@ "_OFXJe0.comment": "Label for allowed tools dropdown", "_OH9xlX.comment": "Hour of the day", "_OIOexo.comment": "Label for schedule days", + "_OMW+Rg.comment": "Label shown while saving the edited action code", "_OMbXig.comment": "Label for the name input", "_OMr20n.comment": "Title for sandbox configuration section", "_OMuMCI.comment": "Header text for workflow properties", @@ -3511,6 +3513,7 @@ "_SToblZ.comment": "Parameters tab description", "_SUaXux.comment": "The description for the connections tab", "_SUo94t.comment": "Title for add files panel", + "_SUqh1Y.comment": "Button to save the edited action code", "_SXb47U.comment": "This is a period in time in seconds. {minutes} is replaced by the number and m is an abbreviation of minutes", "_SY04wn.comment": "Required string parameter to identify action name for formDataMultiValues function", "_SY9ptd.comment": "Description for the actions section", @@ -3783,7 +3786,6 @@ "_XOAcjQ.comment": "Time zone value ", "_XOzn/3.comment": "This is for a label for a badge, it is used for screen readers and not shown on the screen.", "_XPBoDw.comment": "Select option placeholder", - "_XQ3pyc.comment": "Validation error shown when the edited action code is not valid JSON", "_XQ4OCV.comment": "Time zone value ", "_XR4Sd/.comment": "Chatbot user feedback like button title", "_XR5izH.comment": "Label text to connected status", @@ -3976,7 +3978,6 @@ "_avaDe+.comment": "Title for the MCP server workflows section", "_ayaW+i.comment": "Label for the MCP server workflows field", "_az+QCK.comment": "Logic app name validation message text", - "_b/Zr/q.comment": "Label shown while saving the edited action code", "_b0wO2+.comment": "Stateless workflow description", "_b2aL+f.comment": "Text indicating a menu button to pin an action to the side panel", "_b6G9bq.comment": "Label for description of custom encodeUriComponent Function", @@ -4161,6 +4162,7 @@ "_eXWIo2.comment": "Description for parameter default value field", "_eXcejw.comment": "Running status", "_eaEXYa.comment": "Checkbox text for the filter representing all items", + "_eaI2mF.comment": "Validation error shown when the edited action code is not valid JSON", "_eagv8j.comment": "Create logic app workspace text.", "_eb91v1.comment": "Header for the change connection panel", "_ec13/p.comment": "Link text to learn more about Independent Publisher connectors", @@ -4472,6 +4474,7 @@ "_kSXjTx.comment": "Assertion field no description text", "_kU4VfD.comment": "Choice group first choice: Stateful Type", "_kVwJXt.comment": "Time zone value ", + "_kWpQvf.comment": "Button to discard edits made to the action code", "_kXn5e0.comment": "Chabot input placeholder text", "_kZCX7t.comment": "Day of the week", "_kZk/Ed.comment": "ARIA label text for the download link. Do not remove the double single quotes around the display name, as it is needed to wrap the placeholder text.", @@ -4728,7 +4731,6 @@ "_p+zBbS.comment": "Hint shown after 10 seconds when loading Foundry agents takes longer than expected.", "_p/0r2N.comment": "Required string parameter to be used as key for formDataValue function", "_p/Pfr/.comment": "Message indicating that the user does not have write permissions for the role", - "_p/Vu7Z.comment": "Button to discard edits made to the action code", "_p0BE2D.comment": "Button text to trigger clone in the create workflow panel", "_p1IEXb.comment": "Label for button to open dynamic content token picker", "_p2eSD1.comment": "Button text for opening panel for editing workflows", @@ -4980,7 +4982,6 @@ "_ti2c1D.comment": "Button text for moving to the next tab with action count", "_ti5TEd.comment": "Text for cancel button", "_tjQdhq.comment": "Solution type of the template", - "_tjwYOf.comment": "Button to save the edited action code", "_tkkN++.comment": "Description for template profile tab", "_toHITB.comment": "BizTalk Migration category", "_toWTrl.comment": "Search from file list", @@ -5358,7 +5359,6 @@ "avaDe+": "MCP API key", "ayaW+i": "Workflows", "az+QCK": "Logic app name must start with a letter and can only contain letters, digits, \"_\" and \"-\".", - "b/Zr/q": "Saving…", "b0wO2+": "Optimized for low latency, ideal for request-response and processing IoT events.", "b2aL+f": "Pin action", "b6G9bq": "URL encodes the input string", @@ -5543,6 +5543,7 @@ "eXWIo2": "Pre-filled value used if the user doesn't enter anything.", "eXcejw": "In progress", "eaEXYa": "All", + "eaI2mF": "Invalid JSON. Fix the errors before saving.", "eagv8j": "Create logic app workspace", "eb91v1": "Change connection", "ec13/p": "Learn more", @@ -5854,6 +5855,7 @@ "kSXjTx": "No description", "kU4VfD": "Stateful", "kVwJXt": "(UTC+04:00) Abu Dhabi, Muscat", + "kWpQvf": "Discard", "kXn5e0": "Ask a question about this workflow or about Azure Logic Apps as a whole ...", "kZCX7t": "Monday", "kZk/Ed": "Alt/Option + select to download ''{displayName}''", @@ -6110,7 +6112,6 @@ "p+zBbS": "This may take a moment for new connections.", "p/0r2N": "Required. The key name of the form data value to return.", "p/Pfr/": "Missing role write permissions", - "p/Vu7Z": "Discard", "p0BE2D": "Clone", "p1IEXb": "Enter the data from previous step. You can also add data by typing the '/' character.", "p2eSD1": "Edit", @@ -6362,7 +6363,6 @@ "ti2c1D": "Next ({count} selected)", "ti5TEd": "Cancel", "tjQdhq": "Type", - "tjwYOf": "Save", "tkkN++": "Add details to help template users evaluate this template. The profile includes the information shown to users and settings that control how the template is filtered and displayed.", "toHITB": "BizTalk Migration", "toWTrl": "Search", diff --git a/libs/designer-v2/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts b/libs/designer-v2/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts index 460f453f66d..721e91048fb 100644 --- a/libs/designer-v2/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts +++ b/libs/designer-v2/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts @@ -33,9 +33,10 @@ export interface UpdateNodeFromCodeViewPayload { * the designer state. Only the edited node is re-initialized; other nodes (and their * unsaved edits) are left untouched. * - * Note: structural/graph changes (renaming the action key, changing runAfter / nesting, - * adding or removing child actions) are NOT applied here -- those still require the - * whole-workflow code view. + * Changes to the edited node's `runAfter` are reconciled into the graph (via + * replaceOperationDefinition) so the node's position updates. Other structural changes + * (renaming the action key, adding or removing child actions, re-nesting into a different + * scope) are NOT applied here -- those still require the whole-workflow code view. */ export const updateNodeFromCodeView = createAsyncThunk( 'updateNodeFromCodeView', diff --git a/libs/designer-v2/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts b/libs/designer-v2/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts index c8d2e8f836c..3d52e15c05c 100644 --- a/libs/designer-v2/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts +++ b/libs/designer-v2/src/lib/core/state/workflow/__test__/workflowSlice.spec.ts @@ -262,3 +262,102 @@ describe('workflowSlice - replaceOperationDefinition', () => { expect(newState.operations['missing']).toBeUndefined(); }); }); + +describe('workflowSlice - replaceOperationDefinition runAfter graph reconciliation', () => { + // Linear top-level workflow: manual (trigger) -> A -> B -> C + const buildLinearState = (): WorkflowState => + ({ + graph: { + id: 'root', + type: 'GRAPH_NODE', + children: [ + { id: 'manual', type: 'OPERATION_NODE' }, + { id: 'A', type: 'OPERATION_NODE' }, + { id: 'B', type: 'OPERATION_NODE' }, + { id: 'C', type: 'OPERATION_NODE' }, + ], + edges: [ + { id: 'manual-A', source: 'manual', target: 'A', type: 'BUTTON_EDGE' }, + { id: 'A-B', source: 'A', target: 'B', type: 'BUTTON_EDGE' }, + { id: 'B-C', source: 'B', target: 'C', type: 'BUTTON_EDGE' }, + ], + }, + operations: { + manual: { type: 'Request', kind: 'Http' }, + A: { type: 'Compose', inputs: {} }, + B: { type: 'Compose', inputs: {}, runAfter: { A: ['Succeeded'] } }, + C: { type: 'Compose', inputs: {}, runAfter: { B: ['Succeeded'] } }, + }, + nodesMetadata: { + manual: { graphId: 'root', isRoot: true, isTrigger: true } as NodeMetadata, + A: { graphId: 'root' } as NodeMetadata, + B: { graphId: 'root' } as NodeMetadata, + C: { graphId: 'root' } as NodeMetadata, + }, + collapsedGraphIds: {}, + collapsedActionIds: {}, + idReplacements: {}, + newlyAddedOperations: {}, + runInstance: null, + isDirty: false, + workflowKind: undefined, + originalDefinition: {} as LogicAppsV2.WorkflowDefinition, + hostData: { errorMessages: {} }, + agentsGraph: {}, + timelineRepetitionIndex: 0, + changeCount: 0, + timelineRepetitionArray: [], + flowErrors: {}, + }) as unknown as WorkflowState; + + const edgeIds = (state: WorkflowState) => (state.graph?.edges ?? []).map((edge) => edge.id).sort(); + + test('removing runAfter on a top-level node re-attaches it to the trigger', () => { + const initialState = buildLinearState(); + + const operationDefinition = { type: 'Compose', inputs: {} } as unknown as LogicAppsV2.OperationDefinition; + + const newState = workflowSlice.reducer( + initialState, + workflowSlice.actions.replaceOperationDefinition({ nodeId: 'C', operationDefinition }) + ); + + // The B->C edge is gone and a trigger->C edge is created. + expect(edgeIds(newState)).toEqual(['A-B', 'manual-A', 'manual-C']); + }); + + test('changing the runAfter parent moves the edge to the new parent', () => { + const initialState = buildLinearState(); + + const operationDefinition = { + type: 'Compose', + inputs: {}, + runAfter: { A: ['Succeeded'] }, + } as unknown as LogicAppsV2.OperationDefinition; + + const newState = workflowSlice.reducer( + initialState, + workflowSlice.actions.replaceOperationDefinition({ nodeId: 'C', operationDefinition }) + ); + + // C now runs after A instead of B: B->C replaced with A->C. + expect(edgeIds(newState)).toEqual(['A-B', 'A-C', 'manual-A']); + }); + + test('status-only runAfter changes do not alter the edges', () => { + const initialState = buildLinearState(); + + const operationDefinition = { + type: 'Compose', + inputs: {}, + runAfter: { B: ['Failed'] }, + } as unknown as LogicAppsV2.OperationDefinition; + + const newState = workflowSlice.reducer( + initialState, + workflowSlice.actions.replaceOperationDefinition({ nodeId: 'C', operationDefinition }) + ); + + expect(edgeIds(newState)).toEqual(['A-B', 'B-C', 'manual-A']); + }); +}); diff --git a/libs/designer-v2/src/lib/core/state/workflow/workflowSlice.ts b/libs/designer-v2/src/lib/core/state/workflow/workflowSlice.ts index a6db4570e3a..39ccc0e53cf 100644 --- a/libs/designer-v2/src/lib/core/state/workflow/workflowSlice.ts +++ b/libs/designer-v2/src/lib/core/state/workflow/workflowSlice.ts @@ -16,8 +16,8 @@ import type { MoveNodePayload } from '../../parsers/moveNodeInWorkflow'; import { moveNodeInWorkflow } from '../../parsers/moveNodeInWorkflow'; import { pasteScopeInWorkflow } from '../../parsers/pasteScopeInWorkflow'; import type { PasteScopeNodePayload } from '../../parsers/pasteScopeInWorkflow'; -import { addNewEdge, removeEdge, reassignEdgeSources, reassignEdgeTargets } from '../../parsers/restructuringHelpers'; -import { createWorkflowNode, getImmediateSourceNodeIds, transformOperationTitle } from '../../utils/graph'; +import { addNewEdge, removeEdge, reassignEdgeSources, reassignEdgeTargets, applyIsRootNode } from '../../parsers/restructuringHelpers'; +import { createWorkflowEdge, createWorkflowNode, getImmediateSourceNodeIds, transformOperationTitle } from '../../utils/graph'; import { resetWorkflowState, setStateAfterUndoRedo } from '../global'; import type { AddSettingsPayload, NodeOperation } from '../operation/operationMetadataSlice'; import { @@ -41,6 +41,7 @@ import { containsIdTag, containsCaseTag, SUBGRAPH_TYPES, + WORKFLOW_EDGE_TYPES, } from '@microsoft/logic-apps-shared'; import type { MessageLevel } from '@microsoft/designer-ui'; import { getDurationStringPanelMode } from '@microsoft/designer-ui'; @@ -66,6 +67,90 @@ export interface WrapNodesInScopePayload { operation: any; } +/** + * Reconciles the graph edges (and isRoot metadata) for a single node after its + * `runAfter` has changed via an inline code-view edit. This keeps the node's + * visual position in sync with its new run-after dependencies, mirroring how the + * BJS deserializer builds edges from `runAfter`: + * - a node with run-after parents gets one BUTTON_EDGE per parent; + * - a top-level node with no run-after runs after the trigger (trigger edge); + * - a scoped node with no run-after attaches to its scope header (heading edge). + * Structural scope edges that don't represent run-after relationships are left alone. + */ +const reconcileRunAfterEdges = ( + state: WorkflowState, + nodeId: string, + oldRunAfter: Record | undefined, + newRunAfter: Record | undefined +) => { + const nodeMetadata = getRecordEntry(state.nodesMetadata, nodeId); + if (!nodeMetadata || nodeMetadata.isTrigger) { + return; + } + + // Only reconcile when the set of run-after parents actually changed. Status-only + // changes (e.g. Succeeded -> Failed) keep the same edges and need no graph update. + const oldKeys = Object.keys(oldRunAfter ?? {}).sort(); + const newKeys = Object.keys(newRunAfter ?? {}).sort(); + const sameParents = oldKeys.length === newKeys.length && oldKeys.every((key, index) => key === newKeys[index]); + if (sameParents) { + return; + } + + // Resolve the graph (scope) that owns this node's incoming edges. + const graphPath: string[] = []; + let operationGraph: NodeMetadata | undefined = nodeMetadata; + while (operationGraph && !equals(operationGraph.graphId, 'root')) { + graphPath.push(operationGraph.graphId); + operationGraph = getRecordEntry(state.nodesMetadata, operationGraph.graphId); + } + let graph: WorkflowNode | null = state.graph; + for (const id of graphPath.reverse()) { + graph = graph?.children?.find((child) => child.id === id) ?? null; + } + if (!graph) { + return; + } + + // Remove the existing connection edges that point at this node. We only touch + // run-after (BUTTON_EDGE) and scope-attachment (HEADING_EDGE) edges so other + // structural edges (footer/only/hidden) are preserved. + graph.edges = (graph.edges ?? []).filter( + (edge) => edge.target !== nodeId || (edge.type !== WORKFLOW_EDGE_TYPES.BUTTON_EDGE && edge.type !== WORKFLOW_EDGE_TYPES.HEADING_EDGE) + ); + + const pushEdge = (source: string, type?: (typeof WORKFLOW_EDGE_TYPES)[keyof typeof WORKFLOW_EDGE_TYPES]) => { + const edgeId = `${source}-${nodeId}`; + if (!graph?.edges?.some((edge) => edge.id === edgeId)) { + graph?.edges?.push(createWorkflowEdge(source, nodeId, type)); + } + }; + + if (newKeys.length > 0) { + // Node runs after one or more sibling actions. + for (const source of newKeys) { + pushEdge(source); + } + } else if (equals(nodeMetadata.graphId, 'root')) { + // Top-level action with no run-after runs after the trigger. + const triggerNodeId = Object.entries(state.nodesMetadata).find(([, node]) => node?.isTrigger ?? false)?.[0]; + if (triggerNodeId) { + pushEdge(triggerNodeId); + } + } else { + // Scoped action with no run-after attaches to its scope/subgraph header. + const headerNode = graph.children?.find( + (child) => child.type === WORKFLOW_NODE_TYPES.SCOPE_CARD_NODE || child.type === WORKFLOW_NODE_TYPES.SUBGRAPH_CARD_NODE + ); + if (headerNode) { + pushEdge(headerNode.id, WORKFLOW_EDGE_TYPES.HEADING_EDGE); + } + } + + // Keep isRoot metadata (and root run-after cleanup) in sync with the new edges. + applyIsRootNode(state, graph, state.nodesMetadata); +}; + export const initialWorkflowState: WorkflowState = { workflowSpec: 'BJS', workflowKind: undefined, @@ -119,10 +204,14 @@ export const workflowSlice = createSlice({ action: PayloadAction<{ nodeId: string; operationDefinition: LogicAppsV2.OperationDefinition }> ) => { const { nodeId, operationDefinition } = action.payload; - if (!getRecordEntry(state.operations, nodeId)) { + const existingOperation = getRecordEntry(state.operations, nodeId); + if (!existingOperation) { return; } + const oldRunAfter = (existingOperation as LogicAppsV2.ActionDefinition).runAfter ?? undefined; state.operations[nodeId] = operationDefinition; + const newRunAfter = (operationDefinition as LogicAppsV2.ActionDefinition).runAfter ?? undefined; + reconcileRunAfterEdges(state, nodeId, oldRunAfter, newRunAfter); }, addNode: (state: WorkflowState, action: PayloadAction) => { if (!state.graph) { From 02bffdc33093c840a2b3db5824a42d1c23e83b9c Mon Sep 17 00:00:00 2001 From: Riley Evans Date: Tue, 30 Jun 2026 14:40:35 -0500 Subject: [PATCH 4/6] fix(designer-v2): clear stale node variables before re-init on code-view save initializeVariables skips operations with no variables, so editing an action to remove its variable declaration left a stale tokens.variables[nodeId] entry. Deinitialize the node's variables before re-initializing so removed variables are dropped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libs/designer-v2/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts b/libs/designer-v2/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts index 721e91048fb..4a77822343d 100644 --- a/libs/designer-v2/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts +++ b/libs/designer-v2/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts @@ -4,7 +4,7 @@ import { OperationManifestService, getRecordEntry } from '@microsoft/logic-apps- import { createAsyncThunk } from '@reduxjs/toolkit'; import { setIsPanelLoading } from '../../state/panel/panelSlice'; import { initializeNodes } from '../../state/operation/operationMetadataSlice'; -import { initializeTokensAndVariables } from '../../state/tokens/tokensSlice'; +import { deinitializeTokensAndVariables, initializeTokensAndVariables } from '../../state/tokens/tokensSlice'; import { replaceOperationDefinition } from '../../state/workflow/workflowSlice'; import { isManagedMcpOperation } from '../../state/workflow/helper'; import { isTriggerNode } from '../../utils/graph'; @@ -115,6 +115,10 @@ export const updateNodeFromCodeView = createAsyncThunk( const singleOperation = { [nodeId]: serializedOperation }; const outputTokens = graph ? initializeOutputTokensForOperations(nodeData, singleOperation, graph, nodesMetadata) : {}; const variables = initializeVariables(singleOperation, nodeData); + // Clear any existing variable declarations for this node first. initializeVariables skips + // operations with no variables, so without this an edit that removes a node's variables + // would leave stale entries in tokens.variables. + dispatch(deinitializeTokensAndVariables({ id: nodeId })); dispatch(initializeTokensAndVariables({ outputTokens, variables })); await initializeDynamicDataInNodes(getState as () => RootState, dispatch, [nodeId]); From 99a15e428522da95124cce982fcded1d95f436a3 Mon Sep 17 00:00:00 2001 From: Riley Evans Date: Tue, 30 Jun 2026 15:27:10 -0500 Subject: [PATCH 5/6] test(designer-v2): cover code-view edit thunk, tab, and EditableCodeView Adds Vitest suites for the editable code-view feature: - updateNodeFromCodeView thunk: persist+graph reconcile, single-node re-init, variable lifecycle, manifest/swagger/connector/managed-MCP branch selection, no-op guard. - CodeViewTab container: seeding, read-only peek, dirty tracking, JSON validation, discard, and save dispatch. - EditableCodeView (designer-ui): rendering, dirty/saving/error states, read-only, and callback wiring. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../peek/__test__/editableCodeView.spec.tsx | 99 +++++++ .../__test__/updateNodeFromCodeView.spec.ts | 271 ++++++++++++++++++ .../tabs/__test__/codeViewTab.spec.tsx | 131 +++++++++ 3 files changed, 501 insertions(+) create mode 100644 libs/designer-ui/src/lib/peek/__test__/editableCodeView.spec.tsx create mode 100644 libs/designer-v2/src/lib/core/actions/bjsworkflow/__test__/updateNodeFromCodeView.spec.ts create mode 100644 libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/tabs/__test__/codeViewTab.spec.tsx diff --git a/libs/designer-ui/src/lib/peek/__test__/editableCodeView.spec.tsx b/libs/designer-ui/src/lib/peek/__test__/editableCodeView.spec.tsx new file mode 100644 index 00000000000..203c00a7eba --- /dev/null +++ b/libs/designer-ui/src/lib/peek/__test__/editableCodeView.spec.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { EditableCodeView, type EditableCodeViewProps } from '../editableCodeView'; + +// MonacoEditor is heavy and pulls in the real monaco runtime; swap it for a textarea that +// forwards edits through the same onContentChanged contract. +vi.mock('../../editor/monaco', () => ({ + MonacoEditor: ({ + value, + onContentChanged, + readOnly, + }: { + value?: string; + onContentChanged: (e: { value: string }) => void; + readOnly?: boolean; + }) => ( +