diff --git a/Localize/lang/strings.json b/Localize/lang/strings.json index deb81f37b6c..c131c15eda4 100644 --- a/Localize/lang/strings.json +++ b/Localize/lang/strings.json @@ -407,6 +407,7 @@ "5qzZMo": "Loading...", "5rljf7": "Discovery Panel", "5rqV2M": "Failed", + "5sZLU4": "Apply", "5szzYP": "Use this template", "5td/Ux": "Returns a single object containing all properties from the input objects. If both objects have the same property, the property from the second object appears in the final result.", "5wYM6C": "When an evaluation or assessment process starts", @@ -508,6 +509,7 @@ "7ZR1xr": "Add an action", "7aJqIH": "Optional. The locale to be used when formatting (defaults to 'en-us').", "7adnmH": "Back to template library", + "7bEIoW": "This host does not enable editing connection expressions. The existing expression is preserved.", "7bhWPe": "A project with this name already exists in the workspace.", "7cPLnJ": "Do you want to stop the agent chat? This will cancel the workflow.", "7fI0ys": "Refreshing...", @@ -672,6 +674,7 @@ "Aid5oX": "Handoff description", "Ak2Lka": "Not connected to", "AkKqDo": "Built-in Tools", + "Aks6v3": "Connection mode", "AlPxuK": "Description", "AlWFOS": "Collapse chat panel", "Alq4/3": "Hybrid connector", @@ -803,6 +806,7 @@ "DGMwU4": "Use sample payload to generate schema", "DGPz3M": "Copied!", "DHI56r": "Rules engine location", + "DI8VPY": "Connections selected at runtime", "DIDL6K": "Standard logic app", "DIH5g2": "Add knowledge sources to build a knowledge base that the agent uses to generate accurate, context-aware responses and insights.", "DIwFTo": "To generate and test with the latest XSLT, please save the map first.", @@ -890,6 +894,7 @@ "F3IDl8": "Required. The number of a specified time unit to add.", "F3q0Hk": "Cannot paste actions below agents in agent to agent workflows", "F67pEe": "Waiting", + "F6vqih": "Connection expression", "F83QRP": "On a schedule", "F9dR1Q": "Add", "F9yRDC": "Method", @@ -1157,6 +1162,7 @@ "Kv+Pa3": "Testing", "KwGA+K": "Select a Function App resource", "KwYMAL": "Stop chat", + "Kx/APX": "Connection expression setup did not finish. Check the design-time connection and try again.", "L+PY+j": "Required. The number of objects to take from the Collection. Must be a positive integer.", "L0UAzs": "Returns the day of week component of a string timestamp", "L2xC0I": "Set up your knowledge base and add sources that the agent will reference for accuracy.", @@ -1292,6 +1298,7 @@ "NWxGWN": "Enter unique property name", "NaW0ga": "This workflow supports the following states. The state determines how data is managed and retained during execution of workflows.", "Nbl3zN": "Enter a description of the output", + "Ndfl+w": "Enter a valid workflow expression, for example @parameters('connectionName'). Escaped text starting with @@ is not an expression.", "NfFrgQ": "Insert the agent parameter. You can also add by typing '/' in the editor.", "Nh91qA": "Returns the value of the specified variable.", "NhJPUn": "How can I call an external endpoint?", @@ -1627,6 +1634,7 @@ "TxdbTq": "Select a logic app", "TyFREt": "New Variable", "TyXFz0": "Logic App Standard", + "TyZznE": "Existing connection", "Tzq5ot": "Search for an action", "U086AA": "Target schema element", "U0I10w": "(UTC+05:00) Ekaterinburg", @@ -2372,6 +2380,7 @@ "_5qzZMo.comment": "A message shown at the bottom of a list when the next set of data is loading", "_5rljf7.comment": "Discovery Panel", "_5rqV2M.comment": "Indicates that the run has failed", + "_5sZLU4.comment": "Apply the connection expression", "_5szzYP.comment": "Button text to create workflow from this template", "_5td/Ux.comment": "Label for description of custom mergeObjects Function", "_5wYM6C.comment": "Evaluation trigger category description", @@ -2473,6 +2482,7 @@ "_7ZR1xr.comment": "Text on example action node", "_7aJqIH.comment": "Optional locale parameter to apply formatNumber function with", "_7adnmH.comment": "Button to navigate back to the template library", + "_7bEIoW.comment": "Imported expression is preserved when expression authoring is disabled", "_7bhWPe.comment": "Function folder name exists in workspace text", "_7cPLnJ.comment": "Stop chat message", "_7fI0ys.comment": "Button text for refreshing the knowledge hubs list when refresh is in progress", @@ -2637,6 +2647,7 @@ "_Aid5oX.comment": "Label text for the input field to enter a description of the handoff", "_Ak2Lka.comment": "Chatbot text stating connection to api not made", "_AkKqDo.comment": "Header label for the built-in tools section in agent loop settings", + "_Aks6v3.comment": "Accessible label for choosing the connection mode", "_AlPxuK.comment": "This is for a label for a badge, it is used for screen readers and not shown on the screen.", "_AlWFOS.comment": "Collapse button title", "_Alq4/3.comment": "Resource group title", @@ -2768,6 +2779,7 @@ "_DGMwU4.comment": "Button Label for allowing users to generate from schema", "_DGPz3M.comment": "Copied button text", "_DHI56r.comment": "Rules Engine location path label", + "_DI8VPY.comment": "Heading for actions whose connections are selected by expressions", "_DIDL6K.comment": "Subtitle for the MCP server creation panel", "_DIH5g2.comment": "Description displayed above the knowledge hubs list", "_DIwFTo.comment": "Save map info", @@ -2855,6 +2867,7 @@ "_F3IDl8.comment": "Required integer parameter to add to time", "_F3q0Hk.comment": "Message shown when paste is disabled below agents in A2A workflows", "_F67pEe.comment": "Waiting status", + "_F6vqih.comment": "Label for the runtime connection expression editor", "_F83QRP.comment": "Schedule trigger category", "_F9dR1Q.comment": "Add", "_F9yRDC.comment": "Label for the authentication method", @@ -3122,6 +3135,7 @@ "_Kv+Pa3.comment": "Label text for testing publish state", "_KwGA+K.comment": "Select a Function App resource", "_KwYMAL.comment": "Refresh button title", + "_Kx/APX.comment": "Error applying the connection expression", "_L+PY+j.comment": "Required number parameter to get number of objects for take function", "_L0UAzs.comment": "Label for description of custom dayOfWeek Function", "_L2xC0I.comment": "Description displayed when there are no connections and hubs in the logic apps.", @@ -3257,6 +3271,7 @@ "_NWxGWN.comment": "Placeholder text for new property name", "_NaW0ga.comment": "Description for state type choice group.", "_Nbl3zN.comment": "Placeholder for output description field", + "_Ndfl+w.comment": "Validation error for a runtime connection expression", "_NfFrgQ.comment": "Button label for opening the agent parameter token picker", "_Nh91qA.comment": "Label for the description of the custom 'variables' function", "_NhJPUn.comment": "Chatbot suggestion message to call an external endpoint", @@ -3592,6 +3607,7 @@ "_TxdbTq.comment": "Placeholder text for logic app selection", "_TyFREt.comment": "Heading Title for a Variable Without Name", "_TyXFz0.comment": "Subtitle for the panel to create a logic app resource", + "_TyZznE.comment": "Use a concrete existing connection", "_Tzq5ot.comment": "Placeholder text for Action search bar", "_U086AA.comment": "Label for target schema node", "_U0I10w.comment": "Time zone value ", @@ -4049,6 +4065,7 @@ "_cQ/Ocu.comment": "Filter by AI Agent category of connectors", "_cR0MlP.comment": "Browse folder button", "_cR9RtV.comment": "Title for discard modal", + "_cTRaCf.comment": "Choose a connection at runtime using an expression", "_cWFJLe.comment": "Subtitle for skills section", "_cWpWiU.comment": "Diagnostics information for error message. Don't remove the double single quotes around the placeholder text, which is needed to wrap the placeholder text in single quotes.", "_cWrYnn.comment": "Workspace folder path label", @@ -4172,6 +4189,7 @@ "_ekM77J.comment": "Label for workflow Name", "_ekrQN7.comment": "Label indicating that the action configuration is complete", "_elD6+N.comment": "Command for italic text for Mac users", + "_elDTa6.comment": "Status for a connection selected at runtime by an expression", "_emnH/J.comment": "Prerequisites label", "_en/5A3.comment": "Button text to reassign actions", "_ensbBh.comment": "Button text to retry loading connector metadata", @@ -4204,6 +4222,7 @@ "_fLchIJ.comment": "Title for the error message shown when creation of logic app fails", "_fNE/hg.comment": "Text for if image does not show up", "_fNlJSh.comment": "Error message to show when all connections are not connected", + "_fPjwey.comment": "No design-time connection selected", "_fQh72N.comment": "Error shown when the feature connector field is missing", "_fRWxou.comment": "Description for API key connection parameter", "_fRrZKS.comment": "Light mode image label", @@ -4639,6 +4658,7 @@ "_nSan4V.comment": "Reset", "_nTA155.comment": "Required string parameter to identify which property to remove", "_nUCvFK.comment": "Number of selected runs", + "_nUmNgD.comment": "Optional concrete connection used only by the designer", "_nV2Spt.comment": "label for operation details panel component", "_nVDG00.comment": "Time zone value ", "_nVhDGu.comment": "Workflow name field placeholder", @@ -5115,10 +5135,12 @@ "_w/tTbg.comment": "Text displayed while loading knowledge hubs", "_w0pNyJ.comment": "Label text for agent key", "_w16qh+.comment": "Display name for queries in outputs", + "_w19Ytu.comment": "Clarifies that the design-time connection is not a runtime fallback", "_w1QL1r.comment": "Button text for deleting the hub artifacts", "_w2VjJS.comment": "Aria label for closing the MCP server creation panel", "_w2rxzD.comment": "Text to explain that there are no executed tools in the agent iteration", "_w3BZ0u.comment": "Placeholder text for connector search input", + "_w5FZqt.comment": "Explains runtime connection expression behavior", "_w5Hhig.comment": "Day of the week", "_w63mKE.comment": "Label for sources section", "_w6LBmz.comment": "Azure API Management Service label", @@ -5428,6 +5450,7 @@ "cQ/Ocu": "AI Agent", "cR0MlP": "Browse...", "cR9RtV": "Discard changes", + "cTRaCf": "Use expression", "cWFJLe": "Repository skills that provide context for agent execution.", "cWpWiU": "More diagnostic information: x-ms-client-request-id is ''{clientRequestId}''.", "cWrYnn": "Workspace folder", @@ -5551,6 +5574,7 @@ "ekM77J": "Workflow name", "ekrQN7": "Configured", "elD6+N": "Italic (⌘I)", + "elDTa6": "Connection selected at runtime", "emnH/J": "Prerequisites", "en/5A3": "Reassign", "ensbBh": "Retry", @@ -5583,6 +5607,7 @@ "fLchIJ": "Creation failed", "fNE/hg": "Button to add dynamic content if token picker is hidden", "fNlJSh": "All connections must be connected for workflow creation", + "fPjwey": "None", "fQh72N": "At least one featured connector is required for publish.", "fRWxou": "Key will be filled automatically.", "fRrZKS": "Light-mode SAS URL", @@ -6018,6 +6043,7 @@ "nSan4V": "Reset", "nTA155": "Required. The name of the property to remove.", "nUCvFK": "{count} selected", + "nUmNgD": "Design-time connection (optional)", "nV2Spt": "Operation details panel", "nVDG00": "(UTC+14:00) Kiritimati Island", "nVhDGu": "Enter workflow name", @@ -6494,10 +6520,12 @@ "w/tTbg": "Loading...", "w0pNyJ": "Agent Key:", "w16qh+": "Queries", + "w19Ytu": "Used only to browse resources and schemas while editing. This is never a runtime fallback and is not saved with the workflow. Without a selection, enter values manually. Schemas can differ between runtime connections.", "w1QL1r": "Delete", "w2VjJS": "Close MCP server creation panel", "w2rxzD": "This iteration has completed without any tool execution", "w3BZ0u": "Search...", + "w5FZqt": "The expression selects a connection at runtime. No connection is created by the designer.", "w5Hhig": "Sunday", "w63mKE": "Sources", "w6LBmz": "Azure API Management Service", diff --git a/apps/docs/docs/api-reference.md b/apps/docs/docs/api-reference.md index c16b66bafe8..4471474fa50 100644 --- a/apps/docs/docs/api-reference.md +++ b/apps/docs/docs/api-reference.md @@ -61,6 +61,37 @@ interface DesignerProps { } ``` +### Runtime Connection Selection (Designer V2, Standard) + +Designer V2 supports expressions in a Standard `ServiceProvider` action's +`inputs.serviceProviderConfiguration.connectionName`. The +**Change connection > Use expression** editor is enabled by default for supported +actions; no host option is required. Managed API connections, Consumption +workflows, and triggers do not expose expression authoring. Imported expressions +are preserved when their context does not support authoring. Designer V1 is +unchanged and does not offer this editor. + +Expression editing is per action, not bulk connection reassignment. The token +picker does not insert implicit loops. For per-item selection within an existing +loop, enter an explicit expression such as `@items('For_each')?['connectionName']`. + +An expression such as `@parameters('connectionName')` must resolve at runtime to +an existing, case-sensitive key in `connections.json`, for the action's service +provider. Display names are not connection keys. The Designer does not create +connections at runtime or change their credentials. + +The optional **Design-time connection** supplies resource browsing and schema +discovery while editing. This selection is session-only and is not serialized as +a runtime fallback. Without a design-time connection, enter parameter values +manually; connection-dependent browsing is unavailable. Existing values are +preserved. A schema discovered using one connection does not guarantee the same +schema for every runtime target. + +Hosts must keep connection keys stable and preserve connections that may be +selected dynamically, even if no action references them statically. For +multi-tenant workflows, map authorized tenant context to permitted connection +keys rather than accepting an arbitrary caller-supplied key as authorization. + ### Service Configuration The designer requires several services to be configured: diff --git a/libs/designer-ui/__test__/connection-expression-editor-helper.tsx b/libs/designer-ui/__test__/connection-expression-editor-helper.tsx new file mode 100644 index 00000000000..e4a4f503f3d --- /dev/null +++ b/libs/designer-ui/__test__/connection-expression-editor-helper.tsx @@ -0,0 +1,25 @@ +import type { ComponentProps } from 'react'; +import type { LexicalEditor } from 'lexical'; +import { vi } from 'vitest'; + +export { $createParagraphNode, $createTextNode, $getRoot } from 'lexical'; + +// Keep this helper in designer-ui so pnpm resolves the same Lexical dependency +// and conditional ESM entry points as the real editor, but outside src so its +// test-only mocks are not instrumented as production code. +const connectionExpressionEditor = vi.hoisted(() => ({ + current: undefined as LexicalEditor | undefined, +})); + +export { connectionExpressionEditor }; + +vi.mock('@lexical/react/LexicalOnChangePlugin', async (importOriginal) => { + const actual = await importOriginal(); + const { useLexicalComposerContext } = await import('@lexical/react/LexicalComposerContext'); + return { + OnChangePlugin: (props: ComponentProps) => { + [connectionExpressionEditor.current] = useLexicalComposerContext(); + return ; + }, + }; +}); diff --git a/libs/designer-v2/src/lib/common/models/workflow.ts b/libs/designer-v2/src/lib/common/models/workflow.ts index cf14077e1f3..ba0afc49da9 100644 --- a/libs/designer-v2/src/lib/common/models/workflow.ts +++ b/libs/designer-v2/src/lib/common/models/workflow.ts @@ -42,7 +42,21 @@ export type ReferenceKey = string; export type ConnectionReferences = Record; export type NodeId = string; -export type ConnectionMapping = Record; +export interface ExpressionConnectionMapping { + kind: 'expression'; + expression: string; + designTimeReferenceKey?: ReferenceKey; +} + +export type ConnectionMapping = Record; + +export const isExpressionConnectionMapping = (mapping: unknown): mapping is ExpressionConnectionMapping => + typeof mapping === 'object' && + mapping !== null && + 'kind' in mapping && + mapping.kind === 'expression' && + 'expression' in mapping && + typeof mapping.expression === 'string'; export interface WorkflowParameter { name?: string; diff --git a/libs/designer-v2/src/lib/core/actions/bjsworkflow/__test__/serviceProviderConnectionExpressions.spec.ts b/libs/designer-v2/src/lib/core/actions/bjsworkflow/__test__/serviceProviderConnectionExpressions.spec.ts new file mode 100644 index 00000000000..48fc14b2d82 --- /dev/null +++ b/libs/designer-v2/src/lib/core/actions/bjsworkflow/__test__/serviceProviderConnectionExpressions.spec.ts @@ -0,0 +1,1143 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { configureStore } from '@reduxjs/toolkit'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { createElement } from 'react'; +import { IntlProvider } from 'react-intl'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + connectionExpressionEditor, +} from '../../../../../../../designer-ui/__test__/connection-expression-editor-helper'; +import { SettingTokenField, type ChangeState } from '@microsoft/designer-ui'; +import { + ConnectionReferenceKeyFormat, + InitConnectionService, + InitLoggerService, + InitExperimentationServiceService, + InitOperationManifestService, + InitWorkflowService, + LOCAL_STORAGE_KEYS, + ManifestParser, + OperationManifestService, + type Connection, + type Connector, + type LogicAppsV2, + type OpenAPIV2, + type OperationManifest, +} from '@microsoft/logic-apps-shared'; +import { getMockedInitialRootState } from '../../../../__test__/mock-root-state'; +import { isExpressionConnectionMapping, type ConnectionMapping } from '../../../../common/models/workflow'; +import { getReactQueryClient } from '../../../ReactQueryProvider'; +import { Deserialize } from '../../../parsers/BJSWorkflow/BJSDeserializer'; +import * as connectionQueries from '../../../queries/connections'; +import * as dynamicQueries from '../../../queries/connector'; +import connectionsReducer, { + changeConnectionMapping, + initCopiedConnectionMap, + initScopeCopiedConnections, + renameConnectionExpressionParameter, + setNodeConnectionMapping, +} from '../../../state/connection/connectionSlice'; +import { getConnectionReferenceForNodeId } from '../../../state/connection/connectionSelector'; +import operationsReducer, { ErrorLevel, initializeNodes } from '../../../state/operation/operationMetadataSlice'; +import panelReducer from '../../../state/panel/panelSlice'; +import tokensReducer from '../../../state/tokens/tokensSlice'; +import undoRedoReducer from '../../../state/undoRedo/undoRedoSlice'; +import workflowReducer from '../../../state/workflow/workflowSlice'; +import workflowParametersReducer, { updateParameter } from '../../../state/workflowparameters/workflowparametersSlice'; +import type { RootState } from '../../../store'; +import { + getServiceProviderConnectionMapping, + isConnectionExpressionValid, + remapConnectionExpression, +} from '../../../utils/connectors/connectionExpression'; +import { getConnectionId, getConnectionReference, isConnectionReferenceValid } from '../../../utils/connectors/connections'; +import { storeStateHistoryMiddleware } from '../../../utils/middleware'; +import { canInvokeDynamicConnection, getDynamicSchema, getDynamicValues, getFolderItems } from '../../../utils/parameters/dynamicdata'; +import { shouldUseParameterInGroup, updateParameterAndDependencies, validateParameter } from '../../../utils/parameters/helper'; +import { getConnectionMappingForNode, updateNodeConnection, updateNodeConnectionExpression } from '../connections'; +import { copyOperation, pasteOperation, pasteScopeOperation } from '../copypaste'; +import { initializeDynamicDataInNodes, initializeOperationDetailsForManifest } from '../operationdeserializer'; +import { updateNodeFromCodeView } from '../updateNodeFromCodeView'; +import { serializeOperation, serializeWorkflow } from '../serializer'; +import { onRedoClick, onUndoClick } from '../undoRedo'; + +const connectorId = '/serviceProviders/sql'; +const nodeId = 'Query'; +const operationInfo = { connectorId, operationId: 'executeQuery', type: 'ServiceProvider' }; +const manifest = { + properties: { + connection: { required: true, type: 'ServiceProvider' }, + connectionReference: { referenceKeyFormat: ConnectionReferenceKeyFormat.ServiceProvider }, + inputsLocation: ['inputs', 'parameters'], + inputs: { type: 'object', properties: { query: { type: 'string' } } }, + outputs: { type: 'object', properties: {} }, + iconUri: '', + brandColor: '#000000', + }, +} as OperationManifest; +const reference = { api: { id: connectorId }, connection: { id: '/serviceProviders/sql/connections/Sql' } }; +const dynamicBodyManifest: OperationManifest = { + ...manifest, + properties: { + ...manifest.properties, + inputs: { + type: 'object', + properties: { + query: { type: 'string' }, + body: { + type: 'object', + title: 'Payload', + 'x-ms-dynamic-properties': { operationId: 'getPayloadSchema', parameters: {}, itemValuePath: 'schema' }, + }, + }, + required: ['body'], + }, + }, +}; +const bodySchema: OpenAPIV2.SchemaObject = { + type: 'object', + properties: { + message: { type: 'string', title: 'Message' }, + nested: { type: 'object', properties: { enabled: { type: 'boolean' } } }, + rows: { type: 'array', items: { type: 'object', properties: { id: { type: 'integer' } } } }, + selection: { type: 'string', title: 'Selection' }, + }, + required: ['message'], +}; +const editedManualBody = { message: 'manually edited', nested: { enabled: true }, rows: [{ id: 42 }] }; +const originalRangeGeometry = Object.getOwnPropertyDescriptors(Range.prototype); + +beforeAll(() => { + // JSDOM has no range geometry; focused Lexical selection updates need these browser APIs. + Object.defineProperties(Range.prototype, { + getBoundingClientRect: { configurable: true, value: () => new DOMRect() }, + getClientRects: { configurable: true, value: () => [] }, + }); +}); + +afterAll(() => { + for (const key of ['getBoundingClientRect', 'getClientRects']) { + if (originalRangeGeometry[key]) { + Object.defineProperty(Range.prototype, key, originalRangeGeometry[key]); + } else { + Reflect.deleteProperty(Range.prototype, key); + } + } +}); + +const buildState = async (connectionName = "@outputs('Resolve_Connection')"): Promise => { + const base = getMockedInitialRootState(); + const action = { + type: 'ServiceProvider', + inputs: { + serviceProviderConfiguration: { serviceProviderId: connectorId, operationId: 'executeQuery', connectionName }, + parameters: { query: 'original', body: { rows: [{ id: 7 }], selection: "@outputs('Resolve_Connection')" } }, + }, + runAfter: {}, + } as unknown as LogicAppsV2.ServiceProvider; + const definition = { actions: { [nodeId]: action }, triggers: {} } as LogicAppsV2.WorkflowDefinition; + const parsed = Deserialize(definition, null); + const mapping = await getConnectionMappingForNode(action, nodeId, false, OperationManifestService()); + return { + ...base, + designerOptions: { + ...base.designerOptions, + hostOptions: { ...base.designerOptions.hostOptions }, + }, + workflow: { + ...base.workflow, + graph: parsed.graph, + nodesMetadata: parsed.nodesMetadata, + operations: parsed.actionData, + originalDefinition: definition, + workflowKind: 'stateful', + idReplacements: {}, + }, + connections: { + ...base.connections, + connectionsMapping: mapping ?? {}, + connectionReferences: { Sql: reference, sql: { ...reference, connection: { id: '/serviceProviders/sql/connections/sql' } } }, + }, + tokens: { ...base.tokens, outputTokens: { Query: { tokens: [], upstreamNodeIds: [] } } }, + workflowParameters: { + ...base.workflowParameters, + definitions: { defaultConnection: { name: 'defaultConnection', type: 'String', value: 'Sql', isEditable: true } }, + }, + operations: { + ...base.operations, + operationInfo: { [nodeId]: operationInfo }, + dependencies: { [nodeId]: { inputs: {}, outputs: {} } }, + errors: {}, + outputParameters: { Query: { outputs: {} } }, + operationMetadata: { Query: { iconUri: '', brandColor: '' } }, + settings: { Query: {} }, + inputParameters: { + [nodeId]: { + parameterGroups: { + default: { + id: 'default', + description: '', + rawInputs: [], + parameters: [ + { + id: 'query', + parameterKey: 'inputs.$.query', + parameterName: 'query', + label: 'Query', + type: 'string', + required: false, + info: {}, + value: [{ id: 'value', type: 'literal', value: 'edited' }], + }, + ], + }, + }, + }, + }, + }, + } as RootState; +}; + +const makeStore = (initial: RootState) => + configureStore({ + reducer: (state = initial, action): RootState => ({ + ...state, + connections: connectionsReducer(state.connections, action), + workflow: workflowReducer(state.workflow, action), + operations: operationsReducer(state.operations, action), + panel: panelReducer(state.panel, action), + tokens: tokensReducer(state.tokens, action), + workflowParameters: workflowParametersReducer(state.workflowParameters, action), + undoRedo: undoRedoReducer(state.undoRedo, action), + }), + middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false }).concat(storeStateHistoryMiddleware), + }); + +const initializeDynamicBodyStore = async (source: 'imported' | 'authored' = 'imported') => { + vi.spyOn(OperationManifestService(), 'getOperationManifest').mockResolvedValue(dynamicBodyManifest); + const expression = "@outputs('Resolve_Connection')"; + const state = await buildState(source === 'authored' ? 'Sql' : expression); + const store = makeStore(state); + const nodes = await initializeOperationDetailsForManifest(nodeId, state.workflow.operations.Query, {}, false, 'stateful', store.dispatch); + store.dispatch(initializeNodes({ nodes: nodes! })); + if (source === 'authored') { + // A static action without fetched metadata has no expanded children or manual root yet. + expect( + Object.values(store.getState().operations.inputParameters.Query.parameterGroups) + .flatMap((group) => group.parameters) + .some((parameter) => parameter.parameterKey === 'inputs.$.body') + ).toBe(false); + await store.dispatch(updateNodeConnectionExpression({ nodeId, expression })).unwrap(); + } + await initializeDynamicDataInNodes(store.getState, store.dispatch, [nodeId]); + return store; +}; + +const editInputThroughEditor = async (store: ReturnType, parameterKey: string, text: string) => { + const groupEntry = Object.entries(store.getState().operations.inputParameters.Query.parameterGroups).find(([, group]) => + group.parameters.some((parameter) => parameter.parameterKey === parameterKey) + ); + expect(groupEntry, `Expected editable parameter ${parameterKey}`).toBeDefined(); + const [groupId, group] = groupEntry!; + const parameter = group.parameters.find((candidate) => candidate.parameterKey === parameterKey)!; + expect(parameter.hideInUI).toBeFalsy(); + expect(shouldUseParameterInGroup(parameter, group.parameters)).toBe(true); + + let pendingUpdate: Promise | undefined; + const onValueChange = vi.fn(({ value }: ChangeState) => { + pendingUpdate = store + .dispatch( + updateParameterAndDependencies({ + nodeId, + groupId, + parameterId: parameter.id, + // Match ParametersTab's edit callback: edited values supersede the imported value. + properties: { value, preservedValue: undefined }, + isTrigger: false, + operationInfo, + connectionReference: getConnectionReference(store.getState().connections, nodeId), + nodeInputs: store.getState().operations.inputParameters.Query, + dependencies: store.getState().operations.dependencies.Query, + }) + ) + .unwrap(); + }); + connectionExpressionEditor.current = undefined; + const { unmount } = render( + createElement( + IntlProvider, + { locale: 'en' }, + createElement(SettingTokenField, { + ...parameter, + readOnly: parameter.editorOptions?.readOnly || store.getState().designerOptions.readOnly, + tokenEditor: true, + tokenMapping: {}, + getTokenPicker: () => null, + onCastParameter: vi.fn(), + onValueChange, + }) + ) + ); + const editor = screen.getByRole('textbox', { name: `${parameter.label}${parameter.required ? ' Required' : ''}` }); + expect(editor).toBeVisible(); + expect(editor).toHaveAttribute('contenteditable', 'true'); + await waitFor(() => expect(connectionExpressionEditor.current).toBeDefined()); + act(() => editor.focus()); + await act(async () => { + connectionExpressionEditor.current!.update( + () => { + const paragraph = $createParagraphNode().append($createTextNode(text)); + $getRoot().clear().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true } + ); + }); + act(() => editor.blur()); + await waitFor(() => expect(onValueChange).toHaveBeenCalled()); + await pendingUpdate; + expect(onValueChange).toHaveBeenLastCalledWith({ + value: [expect.objectContaining({ type: 'literal', value: text })], + }); + const updated = store + .getState() + .operations.inputParameters.Query.parameterGroups[groupId].parameters.find((candidate) => candidate.id === parameter.id)!; + expect(updated.value).toEqual([expect.objectContaining({ type: 'literal', value: text })]); + expect(updated.preservedValue).toBeUndefined(); + unmount(); +}; + +beforeEach(() => { + vi.restoreAllMocks(); + InitLoggerService([]); + InitWorkflowService({} as any); + InitExperimentationServiceService(undefined); + InitOperationManifestService({ + isSupported: () => true, + isAliasingSupported: () => false, + getOperationManifest: async () => manifest, + getOperationInfo: async () => operationInfo, + isBuiltInConnector: () => false, + } as any); + getReactQueryClient().clear(); +}); + +describe('ServiceProvider runtime connection expressions', () => { + describe.each(['imported', 'authored'] as const)('%s manual root requiredness', (source) => { + const initializeRequirednessStore = async (rootRequired: boolean, childRequired: string[], body?: Record) => { + const requirednessManifest: OperationManifest = { + ...dynamicBodyManifest, + properties: { + ...dynamicBodyManifest.properties, + inputs: { + type: 'object', + properties: { + query: { type: 'string' }, + body: { + type: 'object', + title: 'Payload', + required: childRequired, + 'x-ms-dynamic-properties': { operationId: 'getPayloadSchema', parameters: {}, itemValuePath: 'schema' }, + }, + }, + required: rootRequired ? ['body'] : [], + }, + }, + }; + vi.spyOn(OperationManifestService(), 'getOperationManifest').mockResolvedValue(requirednessManifest); + const expression = "@outputs('Resolve_Connection')"; + const state = await buildState(source === 'authored' ? 'Sql' : expression); + const operation = state.workflow.operations.Query as LogicAppsV2.ServiceProvider; + operation.inputs.parameters = { query: 'original', ...(body === undefined ? {} : { body }) }; + state.workflow.originalDefinition.actions.Query = operation; + const store = makeStore(state); + const nodes = await initializeOperationDetailsForManifest(nodeId, operation, {}, false, 'stateful', store.dispatch); + store.dispatch(initializeNodes({ nodes: nodes! })); + if (source === 'authored') { + expect( + store + .getState() + .operations.inputParameters.Query.parameterGroups.default.parameters.some( + (parameter) => parameter.parameterKey === 'inputs.$.body' + ) + ).toBe(false); + await store.dispatch(updateNodeConnectionExpression({ nodeId, expression })).unwrap(); + } + await initializeDynamicDataInNodes(store.getState, store.dispatch, [nodeId]); + return store; + }; + + it.each([ + { rootRequired: false, childRequired: ['message'] }, + { rootRequired: false, childRequired: [] }, + { rootRequired: true, childRequired: ['message'] }, + { rootRequired: true, childRequired: [] }, + ])( + 'keeps root required=$rootRequired independent of child requirements $childRequired when omitted', + async ({ rootRequired, childRequired }) => { + const schemaRequest = vi.spyOn(dynamicQueries, 'getDynamicSchemaProperties'); + const connectionRequest = vi.spyOn(connectionQueries, 'getConnection'); + const store = await initializeRequirednessStore(rootRequired, childRequired); + const group = store.getState().operations.inputParameters.Query.parameterGroups.default; + const root = group.parameters.find((parameter) => parameter.parameterKey === 'inputs.$.body')!; + expect(root.required).toBe(rootRequired); + expect(root.schema.required).toEqual(childRequired); + expect(group.rawInputs.find((parameter) => parameter.key === 'inputs.$.body')?.required).toBe(rootRequired); + expect(getConnectionReference(store.getState().connections, nodeId)).toBeUndefined(); + await store + .dispatch( + updateParameterAndDependencies({ + nodeId, + groupId: 'default', + parameterId: root.id, + properties: { value: root.value, preservedValue: undefined }, + isTrigger: false, + operationInfo, + connectionReference: undefined, + nodeInputs: store.getState().operations.inputParameters.Query, + dependencies: store.getState().operations.dependencies.Query, + }) + ) + .unwrap(); + + const updated = store + .getState() + .operations.inputParameters.Query.parameterGroups.default.parameters.find((parameter) => parameter.id === root.id)!; + if (rootRequired) { + expect(updated.validationErrors?.length).toBeGreaterThan(0); + await expect(serializeWorkflow(store.getState())).rejects.toMatchObject({ code: 'InvalidParameters' }); + } else { + expect(updated.validationErrors).toEqual([]); + const saved = (await serializeWorkflow(store.getState())).definition.actions.Query as LogicAppsV2.ServiceProvider; + expect(saved.inputs.parameters).toEqual({ query: 'original' }); + expect(saved.inputs.serviceProviderConfiguration.connectionName).toBe("@outputs('Resolve_Connection')"); + } + expect(schemaRequest).not.toHaveBeenCalled(); + expect(connectionRequest).not.toHaveBeenCalled(); + } + ); + + it('retains required and type validation for children when a present optional object is expanded', async () => { + const schema: OpenAPIV2.SchemaObject = { + type: 'object', + properties: { message: { type: 'string' }, count: { type: 'integer' } }, + required: ['message'], + }; + const schemaRequest = vi.spyOn(dynamicQueries, 'getDynamicSchemaProperties').mockResolvedValue(schema); + vi.spyOn(connectionQueries, 'getConnection').mockResolvedValue({ id: reference.connection.id, properties: {} } as Connection); + const store = await initializeRequirednessStore(false, ['message'], { message: 'present', count: 7 }); + const root = store + .getState() + .operations.inputParameters.Query.parameterGroups.default.parameters.find( + (parameter) => parameter.parameterKey === 'inputs.$.body' + )!; + expect(root.required).toBe(false); + expect(root.schema.required).toEqual(['message']); + expect(validateParameter(root, root.value)).toEqual([]); + expect(schemaRequest).not.toHaveBeenCalled(); + await store + .dispatch( + updateNodeConnectionExpression({ + nodeId, + expression: "@outputs('Resolve_Connection')", + designTimeReferenceKey: 'Sql', + }) + ) + .unwrap(); + expect(schemaRequest).toHaveBeenCalled(); + const updateChild = async (key: string, value: string | undefined) => { + const inputs = store.getState().operations.inputParameters.Query; + const child = inputs.parameterGroups.default.parameters.find((parameter) => parameter.parameterKey === key)!; + await store + .dispatch( + updateParameterAndDependencies({ + nodeId, + groupId: 'default', + parameterId: child.id, + properties: { value: value === undefined ? [] : [{ id: 'edited-child', type: 'literal', value }], preservedValue: undefined }, + isTrigger: false, + operationInfo, + connectionReference: getConnectionReference(store.getState().connections, nodeId), + nodeInputs: inputs, + dependencies: store.getState().operations.dependencies.Query, + }) + ) + .unwrap(); + return store + .getState() + .operations.inputParameters.Query.parameterGroups.default.parameters.find((parameter) => parameter.id === child.id)!; + }; + + const missingChild = await updateChild('inputs.$.body.message', undefined); + expect(missingChild.required).toBe(true); + expect(missingChild.validationErrors?.length).toBeGreaterThan(0); + await expect(serializeWorkflow(store.getState())).rejects.toMatchObject({ code: 'InvalidParameters' }); + await updateChild('inputs.$.body.message', 'updated'); + const invalidChild = await updateChild('inputs.$.body.count', 'not-a-number'); + expect(invalidChild.validationErrors?.length).toBeGreaterThan(0); + await expect(serializeWorkflow(store.getState())).rejects.toMatchObject({ code: 'InvalidParameters' }); + const validChild = await updateChild('inputs.$.body.count', '42'); + expect(validChild.validationErrors).toEqual([]); + const saved = (await serializeWorkflow(store.getState())).definition.actions.Query as LogicAppsV2.ServiceProvider; + expect(saved.inputs.parameters.body).toEqual({ message: 'updated', count: 42 }); + }); + }); + + it('keeps a declared dynamic-schema object visible and manually editable without a design-time connection, then saves it', async () => { + const dynamicManifest: OperationManifest = { + ...manifest, + properties: { + ...manifest.properties, + inputs: { + type: 'object', + properties: { + query: { type: 'string' }, + body: { + type: 'object', + title: 'Payload', + 'x-ms-dynamic-properties': { operationId: 'getPayloadSchema', parameters: {}, itemValuePath: 'schema' }, + }, + }, + required: ['body'], + }, + }, + }; + expect(new ManifestParser(dynamicManifest, false).getInputParameters(false, 0)['inputs.$.body'].dynamicSchema).toBeDefined(); + vi.spyOn(OperationManifestService(), 'getOperationManifest').mockResolvedValue(dynamicManifest); + const schemaRequest = vi.spyOn(dynamicQueries, 'getDynamicSchemaProperties'); + const connectionRequest = vi.spyOn(connectionQueries, 'getConnection'); + const state = await buildState(); + const store = makeStore(state); + const nodes = await initializeOperationDetailsForManifest( + nodeId, + state.workflow.operations.Query, + {}, + false, + 'stateful', + store.dispatch + ); + store.dispatch(initializeNodes({ nodes: nodes! })); + await initializeDynamicDataInNodes(store.getState, store.dispatch, [nodeId]); + + const nodeInputs = store.getState().operations.inputParameters.Query; + const groupEntry = Object.entries(nodeInputs.parameterGroups).find(([, group]) => + group.parameters.some((parameter) => parameter.parameterName === 'body') + ); + expect(groupEntry, 'Declared dynamic body must remain available for manual editing without a design-time connection').toBeDefined(); + const [groupId, group] = groupEntry!; + const parameter = group.parameters.find((candidate) => candidate.parameterName === 'body')!; + expect(parameter.type).toBe('object'); + expect(parameter.hideInUI).toBeFalsy(); + expect(shouldUseParameterInGroup(parameter, group.parameters)).toBe(true); + expect(store.getState().operations.dependencies.Query.inputs[parameter.parameterKey]).toBeDefined(); + expect(getConnectionReference(store.getState().connections, nodeId)).toBeUndefined(); + + let pendingUpdate: Promise | undefined; + const onValueChange = vi.fn(({ value }: ChangeState) => { + pendingUpdate = store + .dispatch( + updateParameterAndDependencies({ + nodeId, + groupId, + parameterId: parameter.id, + properties: { value, preservedValue: undefined }, + isTrigger: false, + operationInfo, + connectionReference: undefined, + nodeInputs: store.getState().operations.inputParameters.Query, + dependencies: store.getState().operations.dependencies.Query, + }) + ) + .unwrap(); + }); + connectionExpressionEditor.current = undefined; + render( + createElement( + IntlProvider, + { locale: 'en' }, + createElement(SettingTokenField, { + ...parameter, + readOnly: parameter.editorOptions?.readOnly || store.getState().designerOptions.readOnly, + tokenEditor: true, + tokenMapping: {}, + getTokenPicker: () => null, + onCastParameter: vi.fn(), + onValueChange, + }) + ) + ); + const editor = screen.getByRole('textbox', { name: `${parameter.label}${parameter.required ? ' Required' : ''}` }); + expect(editor).toBeVisible(); + expect(editor).toHaveAttribute('contenteditable', 'true'); + await waitFor(() => expect(connectionExpressionEditor.current).toBeDefined()); + const manualPayload = { message: 'manually edited', nested: { enabled: true }, rows: [{ id: 42 }] }; + act(() => editor.focus()); + await act(async () => { + connectionExpressionEditor.current!.update( + () => { + const paragraph = $createParagraphNode().append($createTextNode(JSON.stringify(manualPayload))); + $getRoot().clear().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true } + ); + }); + act(() => editor.blur()); + await waitFor(() => expect(onValueChange).toHaveBeenCalled()); + await pendingUpdate; + expect(onValueChange).toHaveBeenLastCalledWith({ + value: [expect.objectContaining({ type: 'literal', value: JSON.stringify(manualPayload) })], + }); + + const saved = await serializeWorkflow(store.getState()); + expect((saved.definition.actions.Query as LogicAppsV2.ServiceProvider).inputs.parameters.body).toEqual(manualPayload); + expect((saved.definition.actions.Query as LogicAppsV2.ServiceProvider).inputs.serviceProviderConfiguration.connectionName).toBe( + "@outputs('Resolve_Connection')" + ); + expect(schemaRequest).not.toHaveBeenCalled(); + expect(connectionRequest).not.toHaveBeenCalled(); + }); + + it('authors a no-reference expression on a dynamic-schema action and exposes an editable manual root', async () => { + const schemaRequest = vi.spyOn(dynamicQueries, 'getDynamicSchemaProperties'); + const connectionRequest = vi.spyOn(connectionQueries, 'getConnection'); + const store = await initializeDynamicBodyStore('authored'); + const parameters = Object.values(store.getState().operations.inputParameters.Query.parameterGroups).flatMap( + (group) => group.parameters + ); + const root = parameters.find((parameter) => parameter.parameterKey === 'inputs.$.body'); + expect(root).toMatchObject({ type: 'object', info: { isDynamic: true, dynamicParameterReference: 'inputs.$.body' } }); + expect(getConnectionReference(store.getState().connections, nodeId)).toBeUndefined(); + + await editInputThroughEditor(store, 'inputs.$.body', JSON.stringify(editedManualBody)); + const saved = (await serializeWorkflow(store.getState())).definition.actions.Query as LogicAppsV2.ServiceProvider; + expect(saved.inputs.parameters).toEqual({ query: 'original', body: editedManualBody }); + expect(saved.inputs.serviceProviderConfiguration.connectionName).toBe("@outputs('Resolve_Connection')"); + expect(schemaRequest).not.toHaveBeenCalled(); + expect(connectionRequest).not.toHaveBeenCalled(); + }); + + it.each(['design-time', 'static'] as const)( + 'preserves edited manual body and removed properties when selecting a %s connection', + async (selection) => { + const schemaRequest = vi.spyOn(dynamicQueries, 'getDynamicSchemaProperties').mockResolvedValue(bodySchema); + const connection = { id: reference.connection.id, name: 'Sql', properties: {} } as Connection; + vi.spyOn(connectionQueries, 'getConnection').mockResolvedValue(connection); + const store = await initializeDynamicBodyStore(); + await editInputThroughEditor(store, 'inputs.$.body', JSON.stringify(editedManualBody)); + expect(schemaRequest).not.toHaveBeenCalled(); + const before = (await serializeWorkflow(store.getState())).definition.actions.Query as LogicAppsV2.ServiceProvider; + expect(before.inputs.parameters.body).toEqual(editedManualBody); + expect(before.inputs.parameters.body).not.toHaveProperty('selection'); + + if (selection === 'design-time') { + await store + .dispatch( + updateNodeConnectionExpression({ + nodeId, + expression: "@outputs('Resolve_Connection')", + designTimeReferenceKey: 'Sql', + }) + ) + .unwrap(); + } else { + await store + .dispatch(updateNodeConnection({ nodeId, connector: { id: connectorId, properties: {} } as Connector, connection })) + .unwrap(); + } + + expect(schemaRequest).toHaveBeenCalled(); + expect( + schemaRequest.mock.calls.every( + ([connectionId, providerId]) => connectionId === reference.connection.id && providerId === connectorId + ) + ).toBe(true); + const parameters = Object.values(store.getState().operations.inputParameters.Query.parameterGroups).flatMap( + (group) => group.parameters + ); + expect(parameters.some((parameter) => parameter.parameterKey === 'inputs.$.body')).toBe(false); + expect(parameters.find((parameter) => parameter.parameterKey === 'inputs.$.body.message')?.value).toEqual([ + expect.objectContaining({ type: 'literal', value: editedManualBody.message }), + ]); + const saved = (await serializeWorkflow(store.getState())).definition.actions.Query as LogicAppsV2.ServiceProvider; + expect(saved.inputs.parameters).toEqual({ query: 'original', body: editedManualBody }); + expect(saved.inputs.parameters.body).not.toHaveProperty('selection'); + expect(saved.inputs.serviceProviderConfiguration.connectionName).toBe( + selection === 'static' ? 'Sql' : "@outputs('Resolve_Connection')" + ); + } + ); + + it('removes a design-time connection while retaining editable expanded fields and manual-body edits', async () => { + const expression = "@outputs('Resolve_Connection')"; + const schemaRequest = vi.spyOn(dynamicQueries, 'getDynamicSchemaProperties').mockResolvedValue(bodySchema); + const connectionRequest = vi + .spyOn(connectionQueries, 'getConnection') + .mockResolvedValue({ id: reference.connection.id, name: 'Sql', properties: {} } as Connection); + const store = await initializeDynamicBodyStore(); + await editInputThroughEditor(store, 'inputs.$.body', JSON.stringify(editedManualBody)); + await store.dispatch(updateNodeConnectionExpression({ nodeId, expression, designTimeReferenceKey: 'Sql' })).unwrap(); + expect(schemaRequest).toHaveBeenCalled(); + expect( + ((await serializeWorkflow(store.getState())).definition.actions.Query as LogicAppsV2.ServiceProvider).inputs.parameters.body + ).toEqual(editedManualBody); + schemaRequest.mockClear(); + connectionRequest.mockClear(); + + await store.dispatch(updateNodeConnectionExpression({ nodeId, expression })).unwrap(); + expect(store.getState().connections.connectionsMapping.Query).toEqual({ kind: 'expression', expression }); + expect(getConnectionReference(store.getState().connections, nodeId)).toBeUndefined(); + const parameters = Object.values(store.getState().operations.inputParameters.Query.parameterGroups).flatMap( + (group) => group.parameters + ); + expect(parameters.some((parameter) => parameter.parameterKey === 'inputs.$.body')).toBe(false); + await editInputThroughEditor(store, 'inputs.$.body.message', 'edited after removing design-time'); + + const saved = (await serializeWorkflow(store.getState())).definition.actions.Query as LogicAppsV2.ServiceProvider; + expect(saved.inputs.parameters.body).toEqual({ ...editedManualBody, message: 'edited after removing design-time' }); + expect(saved.inputs.parameters.body).not.toHaveProperty('selection'); + expect(saved.inputs.serviceProviderConfiguration.connectionName).toBe(expression); + expect(schemaRequest).not.toHaveBeenCalled(); + expect(connectionRequest).not.toHaveBeenCalled(); + }); + + it('imports through the real manifest deserializer and saves all unmodeled dynamic inputs', async () => { + const state = await buildState(); + const store = makeStore(state); + const data = await initializeOperationDetailsForManifest( + nodeId, + state.workflow.operations.Query, + {}, + false, + 'stateful', + store.dispatch + ); + expect(data).toHaveLength(1); + store.dispatch(initializeNodes({ nodes: data! })); + await initializeDynamicDataInNodes(store.getState, store.dispatch, [nodeId]); + const saved = await serializeWorkflow(store.getState()); + expect((saved.definition.actions.Query as LogicAppsV2.ServiceProvider).inputs).toEqual( + (state.workflow.operations.Query as LogicAppsV2.ServiceProvider).inputs + ); + }); + + it('round trips Code View expression edits through real deserialization and serialization', async () => { + const state = await buildState('Sql'); + const store = makeStore(state); + const operation = state.workflow.operations.Query as LogicAppsV2.ServiceProvider; + const expression = "@triggerBody()?['connectionName']"; + const edited = { + ...operation, + inputs: { + ...operation.inputs, + serviceProviderConfiguration: { ...operation.inputs.serviceProviderConfiguration, connectionName: expression }, + parameters: { query: 'code-edited', body: { payload: [1, 2] } }, + }, + }; + await store.dispatch(updateNodeFromCodeView({ nodeId, serializedOperation: edited })).unwrap(); + expect(store.getState().connections.connectionsMapping.Query).toEqual({ kind: 'expression', expression }); + const saved = await serializeWorkflow(store.getState()); + expect((saved.definition.actions.Query as LogicAppsV2.ServiceProvider).inputs).toEqual(edited.inputs); + }); + + it.each([ + "@outputs('Resolve_Connection')", + "@triggerBody()?['connectionName']", + "@parameters('defaultConnection')", + "@if(equals(triggerBody()?['region'], 'west'), 'Sql', 'sql')", + "sql-@{parameters('defaultConnection')}", + "@{'Sql'}", + "@'Sql'", + ])('imports and serializes %s without a design-time connection', async (expression) => { + const state = await buildState(expression); + expect(state.connections.connectionsMapping.Query).toEqual({ kind: 'expression', expression }); + const serialized = await serializeWorkflow(state); + expect((serialized.definition.actions.Query as LogicAppsV2.ServiceProvider).inputs.serviceProviderConfiguration).toEqual({ + serviceProviderId: connectorId, + operationId: 'executeQuery', + connectionName: expression, + }); + expect(serialized.connectionReferences).toEqual(state.connections.connectionReferences); + expect((serialized.definition.actions.Query as LogicAppsV2.ServiceProvider).inputs.parameters).toEqual({ + query: 'edited', + body: { rows: [{ id: 7 }], selection: "@outputs('Resolve_Connection')" }, + }); + }); + + it.each([ + ['Sql', 'Sql'], + ['sql', 'sql'], + ['@@outputs()', '@outputs()'], + ['x@@{y}', 'x@{y}'], + ])('keeps the exact literal key %s', async (runtimeName, key) => { + const state = await buildState(runtimeName); + expect(state.connections.connectionsMapping.Query).toBe(key); + const serialized = await serializeOperation(state, nodeId, { skipValidation: true }); + expect((serialized as LogicAppsV2.ServiceProvider).inputs.serviceProviderConfiguration.connectionName).toBe(runtimeName); + }); + + it('preserves malformed input on import but rejects validation-enabled save', async () => { + const expression = '@if('; + const state = await buildState(expression); + expect(state.connections.connectionsMapping.Query).toEqual({ kind: 'expression', expression }); + expect(getConnectionId(state.connections, nodeId)).toBe(''); + expect(isConnectionExpressionValid(expression)).toBe(false); + await expect(serializeWorkflow(state)).rejects.toThrow(); + const saved = await serializeWorkflow(state, { skipValidation: true }); + expect((saved.definition.actions.Query as LogicAppsV2.ServiceProvider).inputs.serviceProviderConfiguration.connectionName).toBe( + expression + ); + }); + + it('uses only the exact design-time reference and never serializes it as the runtime value', async () => { + const state = await buildState(); + state.connections.connectionsMapping.Query = { kind: 'expression', expression: '@triggerBody()', designTimeReferenceKey: 'Sql' }; + expect(getConnectionReference(state.connections, nodeId)).toBe(reference); + const saved = await serializeOperation(state, nodeId); + expect((saved as LogicAppsV2.ServiceProvider).inputs.serviceProviderConfiguration.connectionName).toBe('@triggerBody()'); + state.connections.connectionsMapping.Query.designTimeReferenceKey = 'SQL'; + expect(getConnectionReference(state.connections, nodeId)).toBeUndefined(); + state.connections.connectionsMapping.Query = 'sql'; + expect(getConnectionId(state.connections, nodeId)).toBe('/serviceProviders/sql/connections/sql'); + }); + + it('does not make connection, dynamic-list, schema, or tree requests without a design-time selection', async () => { + const state = await buildState(); + const connectionSpy = vi.spyOn(connectionQueries, 'getConnection'); + const listSpy = vi.spyOn(dynamicQueries, 'getListDynamicValues'); + const schemaSpy = vi.spyOn(dynamicQueries, 'getDynamicSchemaProperties'); + const treeSpy = vi.spyOn(dynamicQueries, 'getDynamicTreeItems'); + const nodeInputs = state.operations.inputParameters.Query; + const selected = getConnectionReference(state.connections, nodeId); + expect(await isConnectionReferenceValid(operationInfo, selected)).toBe(false); + await initializeDynamicDataInNodes(() => state, vi.fn(), [nodeId]); + expect(await getDynamicValues({} as any, nodeInputs, operationInfo, selected, {}, {})).toEqual([]); + expect(await getDynamicSchema({} as any, nodeInputs, operationInfo, selected, [], {}, {})).toBeNull(); + expect(await getFolderItems(undefined, {} as any, nodeInputs, operationInfo, selected, {}, {})).toEqual([]); + for (const spy of [connectionSpy, listSpy, schemaSpy, treeSpy]) { + expect(spy).not.toHaveBeenCalled(); + } + expect(canInvokeDynamicConnection({ connectorId: 'builtin', operationId: 'compose' }, undefined)).toBe(true); + expect(state.operations.inputParameters.Query).toBe(nodeInputs); + }); + + it('keeps case-distinct design-time cache entries separate and refuses direct expression resource requests', async () => { + expect(connectionQueries.getConnectionQueryKey(reference.connection.id)).not.toBe( + connectionQueries.getConnectionQueryKey('/serviceProviders/sql/connections/sql') + ); + expect(await connectionQueries.getConnection("@outputs('Resolve_Connection')", connectorId, true)).toBeNull(); + }); + + it('does not send dynamic requests through a wrong-provider design-time reference', async () => { + const state = await buildState(); + state.connections.connectionReferences.Sql = { + api: { id: '/serviceProviders/azureblob' }, + connection: { id: '/serviceProviders/azureblob/connections/Sql' }, + }; + state.connections.connectionsMapping.Query = { kind: 'expression', expression: '@triggerBody()', designTimeReferenceKey: 'Sql' }; + const selected = getConnectionReference(state.connections, nodeId); + const nodeInputs = state.operations.inputParameters.Query; + const requests = [ + vi.spyOn(connectionQueries, 'getConnection'), + vi.spyOn(dynamicQueries, 'getListDynamicValues'), + vi.spyOn(dynamicQueries, 'getDynamicSchemaProperties'), + vi.spyOn(dynamicQueries, 'getDynamicTreeItems'), + ]; + + expect(selected).toBe(state.connections.connectionReferences.Sql); + expect(canInvokeDynamicConnection(operationInfo, reference)).toBe(true); + expect(canInvokeDynamicConnection(operationInfo, selected)).toBe(false); + expect(await getDynamicValues({} as any, nodeInputs, operationInfo, selected, {}, {})).toEqual([]); + expect(await getDynamicSchema({} as any, nodeInputs, operationInfo, selected, [], {}, {})).toBeNull(); + expect(await getFolderItems(undefined, {} as any, nodeInputs, operationInfo, selected, {}, {})).toEqual([]); + for (const request of requests) { + expect(request).not.toHaveBeenCalled(); + } + }); + + it('resolves exact provider connection IDs without using a resource fallback for a differently cased key', async () => { + const upperCaseConnection = { id: `${connectorId}/connections/Sql`, name: 'Sql' }; + const lowerCaseConnection = { id: `${connectorId}/connections/sql`, name: 'sql' }; + const resourceFallback = vi.fn().mockResolvedValue(upperCaseConnection); + InitConnectionService({ + getConnections: vi.fn().mockResolvedValue([upperCaseConnection, lowerCaseConnection]), + getConnection: resourceFallback, + } as any); + + expect(await connectionQueries.getConnection(upperCaseConnection.id, connectorId, true)).toEqual(upperCaseConnection); + expect(await connectionQueries.getConnection(lowerCaseConnection.id, connectorId, true)).toEqual(lowerCaseConnection); + expect(await connectionQueries.getConnection(`${connectorId}/connections/SQL`, connectorId, true)).toBeNull(); + expect(resourceFallback).not.toHaveBeenCalled(); + }); + + it('ignores stale connection errors for a valid expression, not malformed expressions', async () => { + const state = await buildState(); + state.operations.errors.Query = { [ErrorLevel.Connection]: { message: 'old static connection', level: ErrorLevel.Connection } } as any; + await expect(serializeWorkflow(state)).resolves.toBeDefined(); + state.connections.connectionsMapping.Query = { kind: 'expression', expression: '@broken(' }; + await expect(serializeWorkflow(state)).rejects.toThrow(); + }); + + it.each(['stateful', 'stateless'] as const)( + 'authors expressions for Standard %s with default host options and supports real undo/redo', + async (workflowKind) => { + const state = await buildState('Sql'); + state.workflow.workflowKind = workflowKind; + expect(state.designerOptions.hostOptions).toEqual(getMockedInitialRootState().designerOptions.hostOptions); + const store = makeStore(state); + const originalInputs = store.getState().operations.inputParameters.Query; + await store.dispatch(updateNodeConnectionExpression({ nodeId, expression: "@parameters('defaultConnection')" })).unwrap(); + expect(store.getState().operations.inputParameters.Query).toBe(originalInputs); + expect(store.getState().workflow.isDirty).toBe(true); + await store.dispatch(onUndoClick()); + expect(store.getState().connections.connectionsMapping.Query).toBe('Sql'); + await store.dispatch(onRedoClick()); + expect(store.getState().connections.connectionsMapping.Query).toEqual({ + kind: 'expression', + expression: "@parameters('defaultConnection')", + }); + await store + .dispatch( + updateNodeConnection({ + nodeId, + connector: { id: connectorId, properties: {} } as Connector, + connection: { id: reference.connection.id, properties: {} } as Connection, + }) + ) + .unwrap(); + expect(store.getState().connections.connectionsMapping.Query).toBe('Sql'); + expect(store.getState().operations.inputParameters.Query.parameterGroups.default.parameters[0].value).toEqual( + originalInputs.parameterGroups.default.parameters[0].value + ); + await store.dispatch(onUndoClick()).unwrap(); + expect(store.getState().connections.connectionsMapping.Query).toEqual({ + kind: 'expression', + expression: "@parameters('defaultConnection')", + }); + await store.dispatch(onRedoClick()).unwrap(); + expect(store.getState().connections.connectionsMapping.Query).toBe('Sql'); + } + ); + + it('rejects invalid authoring and nonexistent or incorrectly cased design-time keys without replacing the mapping', async () => { + const store = makeStore(await buildState('Sql')); + await expect(store.dispatch(updateNodeConnectionExpression({ nodeId, expression: '@if(' })).unwrap()).rejects.toThrow(); + await expect( + store.dispatch(updateNodeConnectionExpression({ nodeId, expression: '@triggerBody()', designTimeReferenceKey: 'SQL' })).unwrap() + ).rejects.toThrow(); + expect(store.getState().connections.connectionsMapping.Query).toBe('Sql'); + }); + + it.each<[string, (state: RootState) => void]>([ + [ + 'Consumption workflow', + (state) => { + state.workflow.workflowKind = undefined; + }, + ], + [ + 'trigger', + (state) => { + state.workflow.nodesMetadata.Query.isTrigger = true; + }, + ], + [ + 'managed API action', + (state) => { + state.operations.operationInfo.Query = { ...operationInfo, type: 'ApiConnection', connectorId: '/managedApis/sql' }; + }, + ], + [ + 'missing operation metadata', + (state) => { + delete state.operations.operationInfo.Query; + }, + ], + [ + 'read-only mode', + (state) => { + state.designerOptions.readOnly = true; + }, + ], + [ + 'monitoring mode', + (state) => { + state.designerOptions.isMonitoringView = true; + }, + ], + ])('rejects direct expression authoring with %s without changing the operation', async (_mode, configure) => { + const state = await buildState('Sql'); + configure(state); + const store = makeStore(state); + const originalState = store.getState(); + + await expect(store.dispatch(updateNodeConnectionExpression({ nodeId, expression: '@triggerBody()' })).unwrap()).rejects.toThrow( + _mode === 'read-only mode' || _mode === 'monitoring mode' + ? 'Connection expression editing is not enabled.' + : 'Connection expressions are only supported on Standard service provider actions.' + ); + + expect(store.getState().connections).toBe(originalState.connections); + expect(store.getState().operations.inputParameters).toBe(originalState.operations.inputParameters); + expect(store.getState().workflow.isDirty).toBe(originalState.workflow.isDirty); + }); + + it('remaps action and parameter references without changing connection-name literals', async () => { + const expression = "@if(equals(outputs('Resolve_Connection'), 'Resolve_Connection'), parameters('defaultConnection'), 'Sql')"; + expect(remapConnectionExpression(expression, { Resolve_Connection: 'Resolve_Renamed' }, { defaultConnection: 'preferred' })).toBe( + "@if(equals(outputs('Resolve_Renamed'), 'Resolve_Connection'), parameters('preferred'), 'Sql')" + ); + const state = await buildState(expression); + state.workflow.idReplacements = { Resolve_Connection: 'Resolve_Renamed' }; + const store = makeStore(state); + store.dispatch(updateParameter({ id: 'defaultConnection', newDefinition: { name: 'preferred', type: 'String', value: 'Sql' } } as any)); + const saved = await serializeOperation(store.getState(), nodeId); + expect((saved as LogicAppsV2.ServiceProvider).inputs.serviceProviderConfiguration.connectionName).toContain("parameters('preferred')"); + expect((saved as LogicAppsV2.ServiceProvider).inputs.serviceProviderConfiguration.connectionName).toContain( + "outputs('Resolve_Renamed')" + ); + }); + + it('retains expression mappings through single and scope clipboard state, even without references', async () => { + const state = await buildState(); + const store = makeStore(state); + await store.dispatch(copyOperation({ nodeId })); + const copied = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEYS.CLIPBOARD) ?? '{}'); + expect(copied.nodeConnectionData).toEqual(state.connections.connectionsMapping.Query); + expect(copied.nodeData.nodeInputs.preservedConnectionInputs.parameters.body.rows).toEqual([{ id: 7 }]); + await store + .dispatch( + pasteOperation({ + nodeId: copied.nodeId, + nodeData: copied.nodeData, + nodeTokenData: copied.nodeTokenData, + operationInfo: copied.nodeOperationInfo, + connectionData: copied.nodeConnectionData, + relationshipIds: { graphId: 'root', parentId: nodeId }, + }) + ) + .unwrap(); + const pastedId = Object.keys(store.getState().connections.connectionsMapping).find((id) => id !== nodeId)!; + const pasted = await serializeOperation(store.getState(), pastedId); + expect((pasted as LogicAppsV2.ServiceProvider).inputs.serviceProviderConfiguration.connectionName).toBe( + "@outputs('Resolve_Connection')" + ); + expect((pasted as LogicAppsV2.ServiceProvider).inputs.parameters.body.rows).toEqual([{ id: 7 }]); + store.dispatch(initCopiedConnectionMap({ connectionReferences: { Copy: copied.nodeConnectionData } })); + expect(store.getState().connections.connectionsMapping.Copy).toEqual(copied.nodeConnectionData); + const scopeData = getConnectionReferenceForNodeId(state.connections, nodeId); + store.dispatch(initScopeCopiedConnections({ ScopeCopy: scopeData! })); + expect(store.getState().connections.connectionsMapping.ScopeCopy).toEqual(copied.nodeConnectionData); + const renamed = connectionsReducer( + store.getState().connections, + renameConnectionExpressionParameter({ oldName: 'missing', newName: 'new' }) + ); + expect(renamed.connectionsMapping.Copy).toEqual(copied.nodeConnectionData); + }); + + it('stores a concrete selection over an expression without losing other references', async () => { + const state = await buildState(); + const changed = connectionsReducer( + state.connections, + changeConnectionMapping({ nodeId, connectorId, connectionId: reference.connection.id }) + ); + expect(changed.connectionsMapping.Query).toBe('Sql'); + expect(changed.connectionReferences).toEqual(state.connections.connectionReferences); + const mapping: ConnectionMapping[string] = getServiceProviderConnectionMapping('@triggerBody()'); + expect(isExpressionConnectionMapping(mapping)).toBe(true); + expect(connectionsReducer(changed, setNodeConnectionMapping({ nodeId, mapping })).connectionsMapping.Query).toEqual(mapping); + }); + + it('remaps runtime references during a real scope paste and restores connection state on undo', async () => { + const state = await buildState(); + const store = makeStore(state); + const source = state.workflow.operations.Query as LogicAppsV2.ServiceProvider; + const action = (expression: string) => ({ + ...source, + inputs: { + ...source.inputs, + serviceProviderConfiguration: { ...source.inputs.serviceProviderConfiguration, connectionName: expression }, + }, + }); + await store + .dispatch( + pasteScopeOperation({ + nodeId: 'Container', + serializedValue: { + type: 'Scope', + actions: { + Query: action('@triggerBody()'), + Follow_Query: { ...action("@outputs('Query')"), runAfter: { Query: ['Succeeded'] } }, + }, + runAfter: {}, + }, + relationshipIds: { graphId: 'root', parentId: nodeId }, + allConnectionData: {}, + staticResults: {}, + upstreamNodeIds: [], + }) + ) + .unwrap(); + const mappings = store.getState().connections.connectionsMapping; + const renamedQuery = Object.keys(mappings).find((id) => { + const mapping = mappings[id]; + return id !== nodeId && isExpressionConnectionMapping(mapping) && mapping.expression === '@triggerBody()'; + })!; + expect(renamedQuery).toBeTruthy(); + expect(mappings.Follow_Query).toEqual({ kind: 'expression', expression: `@outputs('${renamedQuery}')` }); + const saved = await serializeOperation(store.getState(), 'Follow_Query'); + expect((saved as LogicAppsV2.ServiceProvider).inputs.serviceProviderConfiguration.connectionName).toBe(`@outputs('${renamedQuery}')`); + await store.dispatch(onUndoClick()); + expect(store.getState().connections.connectionsMapping).toEqual(state.connections.connectionsMapping); + await store.dispatch(onRedoClick()); + expect(store.getState().connections.connectionsMapping.Follow_Query).toEqual(mappings.Follow_Query); + }); + + it('does not resurrect a cleared input while retaining unmodeled dynamic input values', async () => { + const state = await buildState(); + state.operations.inputParameters.Query.parameterGroups.default.parameters[0].value = []; + state.operations.inputParameters.Query.parameterGroups.default.parameters[0].preservedValue = undefined; + const saved = await serializeOperation(state, nodeId); + expect((saved as LogicAppsV2.ServiceProvider).inputs.parameters.query).toBeUndefined(); + expect((saved as LogicAppsV2.ServiceProvider).inputs.parameters.body.rows).toEqual([{ id: 7 }]); + }); + + it.each([ + ['required', 'dynamic', 'imported', 'string', ''], + ['type', 'dynamic', 'imported', 'integer', 'not-a-number'], + ['required', 'dynamic', 'authored', 'string', ''], + ['type', 'dynamic', 'authored', 'integer', 'not-a-number'], + ['required', 'static', 'imported', 'string', ''], + ['type', 'static', 'imported', 'integer', 'not-a-number'], + ['required', 'static', 'authored', 'string', ''], + ['type', 'static', 'authored', 'integer', 'not-a-number'], + ])( + 'preserves %s validation for a %s parameter with an %s runtime connection expression', + async (_validation, parameterKind, source, type, value) => { + const expression = "@outputs('Resolve_Connection')"; + const state = await buildState(source === 'authored' ? 'Sql' : expression); + const parameter = state.operations.inputParameters.Query.parameterGroups.default.parameters[0]; + parameter.info.isDynamic = parameterKind === 'dynamic'; + parameter.type = type; + parameter.required = true; + parameter.value = value ? [{ id: 'invalid-value', type: 'literal', value }] : []; + const validationErrors = validateParameter(parameter, parameter.value); + expect(validationErrors.length).toBeGreaterThan(0); + parameter.validationErrors = validationErrors; + + const store = makeStore(state); + if (source === 'authored') { + await store.dispatch(updateNodeConnectionExpression({ nodeId, expression })).unwrap(); + } + + expect(store.getState().connections.connectionsMapping.Query).toEqual({ kind: 'expression', expression }); + expect(getConnectionReference(store.getState().connections, nodeId)).toBeUndefined(); + expect(store.getState().operations.inputParameters.Query.parameterGroups.default.parameters[0].validationErrors).toEqual( + validationErrors + ); + await expect(serializeWorkflow(store.getState())).rejects.toMatchObject({ code: 'InvalidParameters' }); + } + ); +}); diff --git a/libs/designer-v2/src/lib/core/actions/bjsworkflow/__test__/updateNodeFromCodeView.spec.ts b/libs/designer-v2/src/lib/core/actions/bjsworkflow/__test__/updateNodeFromCodeView.spec.ts index 7bc4ef750a9..239d2b3fef5 100644 --- a/libs/designer-v2/src/lib/core/actions/bjsworkflow/__test__/updateNodeFromCodeView.spec.ts +++ b/libs/designer-v2/src/lib/core/actions/bjsworkflow/__test__/updateNodeFromCodeView.spec.ts @@ -6,7 +6,7 @@ import workflowReducer from '../../../state/workflow/workflowSlice'; import operationsReducer, { initializeNodes } from '../../../state/operation/operationMetadataSlice'; import tokensReducer, { initializeTokensAndVariables } from '../../../state/tokens/tokensSlice'; import panelReducer from '../../../state/panel/panelSlice'; -import connectionsReducer from '../../../state/connection/connectionSlice'; +import connectionsReducer, { initializeConnectionReferences, setNodeConnectionMapping } from '../../../state/connection/connectionSlice'; import workflowParametersReducer from '../../../state/workflowparameters/workflowparametersSlice'; import type { WorkflowState, NodeMetadata } from '../../../state/workflow/workflowInterfaces'; @@ -173,6 +173,59 @@ describe('updateNodeFromCodeView', () => { expect(edgeIds).toEqual(['A-B', 'manual-A', 'manual-C']); }); + test('synchronizes exact literal, expression, and literal connection mappings using the real import helper', async () => { + const operation = (connectionName: string) => + ({ + type: 'ServiceProvider', + inputs: { + serviceProviderConfiguration: { + serviceProviderId: '/serviceProviders/sql', + operationId: 'executeQuery', + connectionName, + }, + parameters: { query: 'select 1', body: { preserved: true } }, + }, + }) as unknown as LogicAppsV2.OperationDefinition; + + await store.dispatch(updateNodeFromCodeView({ nodeId: 'C', serializedOperation: operation('Sql') })).unwrap(); + expect(store.getState().connections.connectionsMapping.C).toBe('Sql'); + const expression = "@if(equals(triggerBody()?['region'], 'west'), 'Sql', 'sql')"; + await store.dispatch(updateNodeFromCodeView({ nodeId: 'C', serializedOperation: operation(expression) })).unwrap(); + expect(store.getState().connections.connectionsMapping.C).toEqual({ kind: 'expression', expression }); + expect((store.getState().workflow.operations.C as any).inputs.parameters.body).toEqual({ preserved: true }); + await store.dispatch(updateNodeFromCodeView({ nodeId: 'C', serializedOperation: operation('sql') })).unwrap(); + expect(store.getState().connections.connectionsMapping.C).toBe('sql'); + }); + + test('keeps imported malformed expressions out of reference IDs and preserves a real design-time selection only for the same connector', async () => { + const operation = (connectionName: string, serviceProviderId = '/serviceProviders/sql') => + ({ + type: 'ServiceProvider', + inputs: { serviceProviderConfiguration: { serviceProviderId, operationId: 'executeQuery', connectionName } }, + }) as unknown as LogicAppsV2.OperationDefinition; + store.dispatch( + initializeConnectionReferences({ + Sql: { api: { id: '/serviceProviders/sql' }, connection: { id: '/serviceProviders/sql/connections/Sql' } }, + }) + ); + store.dispatch( + setNodeConnectionMapping({ + nodeId: 'C', + mapping: { kind: 'expression', expression: '@triggerBody()', designTimeReferenceKey: 'Sql' }, + }) + ); + await store.dispatch(updateNodeFromCodeView({ nodeId: 'C', serializedOperation: operation('@if(') })).unwrap(); + expect(store.getState().connections.connectionsMapping.C).toEqual({ + kind: 'expression', + expression: '@if(', + designTimeReferenceKey: 'Sql', + }); + await store + .dispatch(updateNodeFromCodeView({ nodeId: 'C', serializedOperation: operation('@triggerBody()', '/serviceProviders/other') })) + .unwrap(); + expect(store.getState().connections.connectionsMapping.C).toEqual({ kind: 'expression', expression: '@triggerBody()' }); + }); + test('re-initializes only the edited node and preserves other nodes', async () => { seedNode(store, 'A'); // simulate another node with existing (unsaved) metadata diff --git a/libs/designer-v2/src/lib/core/actions/bjsworkflow/connections.ts b/libs/designer-v2/src/lib/core/actions/bjsworkflow/connections.ts index 835eefe0efb..caeef15bc82 100644 --- a/libs/designer-v2/src/lib/core/actions/bjsworkflow/connections.ts +++ b/libs/designer-v2/src/lib/core/actions/bjsworkflow/connections.ts @@ -1,5 +1,7 @@ import Constants, { MCP_AUTH_PROPERTY_KEYS, usesMcpManagedIdentityFallback } from '../../../common/constants'; -import type { ApiHubAuthentication } from '../../../common/models/workflow'; +import { isExpressionConnectionMapping, type ApiHubAuthentication, type ConnectionMapping } from '../../../common/models/workflow'; +import { getServiceProviderConnectionMapping, isConnectionExpressionValid } from '../../utils/connectors/connectionExpression'; +import { canInvokeDynamicConnection } from '../../utils/parameters/dynamicdata'; import { AgentUtils, isOpenApiSchemaVersion } from '../../../common/utilities/Utils'; import type { DeserializedWorkflow } from '../../parsers/BJSWorkflow/BJSDeserializer'; import { getConnection, getUniqueConnectionName, updateNewConnectionInQueryCache } from '../../queries/connections'; @@ -9,10 +11,16 @@ import { changeConnectionMapping, changeConnectionMappingsForNodes, initializeConnectionsMappings, + setNodeConnectionMapping, } from '../../state/connection/connectionSlice'; import { changeConnectionMapping as changeTemplateConnectionMapping } from '../../state/templates/workflowSlice'; import type { NodeOperation } from '../../state/operation/operationMetadataSlice'; -import { updateErrorDetails, updateNodeParameters } from '../../state/operation/operationMetadataSlice'; +import { + updateErrorDetails, + updateNodeParameters, + updateNodeParameterGroups, + DynamicLoadStatus, +} from '../../state/operation/operationMetadataSlice'; import type { RootState as TemplateRootState } from '../../state/templates/store'; import type { RootState } from '../../store'; import { @@ -50,6 +58,7 @@ import { LogEntryLevel, foundryServiceConnectionRegex, microsoftFoundryModelsRegex, + isServiceProviderOperation, } from '@microsoft/logic-apps-shared'; import type { Dispatch } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit'; @@ -57,6 +66,8 @@ import { openPanel, setIsCreatingConnection, setIsPanelLoading } from '../../sta import type { PanelMode } from '../../state/panel/panelTypes'; import { setIsWorkflowDirty } from '../../state/workflow/workflowSlice'; import { createLiteralValueSegment } from '../../utils/parameters/segment'; +import { getCustomSwaggerIfNeeded, getInputParametersFromManifest } from './initialize'; +import { serializeOperation } from './serializer'; export interface ConnectionPayload { nodeId: string; connector: Connector; @@ -273,6 +284,108 @@ export const updateNodeConnection = createAsyncThunk( } ); +export const updateNodeConnectionExpression = createAsyncThunk( + 'updateNodeConnectionExpression', + async ( + { nodeId, expression, designTimeReferenceKey }: { nodeId: string; expression: string; designTimeReferenceKey?: string }, + { dispatch, getState } + ): Promise => { + const state = getState() as RootState; + if (state.designerOptions.readOnly || state.designerOptions.isMonitoringView) { + throw new Error('Connection expression editing is not enabled.'); + } + const operationInfo = getRecordEntry(state.operations.operationInfo, nodeId); + if ( + !operationInfo || + !isServiceProviderOperation(operationInfo.type) || + !state.workflow.workflowKind || + isTriggerNode(nodeId, state.workflow.nodesMetadata) + ) { + throw new Error('Connection expressions are only supported on Standard service provider actions.'); + } + if (!isConnectionExpressionValid(expression)) { + throw new Error('Invalid connection expression.'); + } + const reference = + designTimeReferenceKey && Object.hasOwn(state.connections.connectionReferences, designTimeReferenceKey) + ? state.connections.connectionReferences[designTimeReferenceKey] + : undefined; + if ( + designTimeReferenceKey && + (!reference || !equals(reference.api.id, operationInfo.connectorId) || !canInvokeDynamicConnection(operationInfo, reference)) + ) { + throw new Error('Select an existing connection for this service provider.'); + } + dispatch( + setNodeConnectionMapping({ + nodeId, + mapping: { kind: 'expression', expression, ...(designTimeReferenceKey ? { designTimeReferenceKey } : {}) }, + }) + ); + const dependencies = state.operations.dependencies[nodeId]?.inputs ?? {}; + const parameters = Object.entries(state.operations.inputParameters[nodeId]?.parameterGroups ?? {}).flatMap(([groupId, group]) => + group.parameters + .filter((parameter) => parameter.info.isDynamic || parameter.dynamicData || dependencies[parameter.parameterKey]) + .map((parameter) => ({ + groupId, + parameterId: parameter.id, + propertiesToUpdate: { + dynamicData: { status: DynamicLoadStatus.NOTSTARTED }, + ...(dependencies[parameter.parameterKey]?.dependencyType === 'ListValues' + ? { editorOptions: { ...parameter.editorOptions, options: [] } } + : {}), + }, + })) + ); + if (parameters.length) { + dispatch(updateNodeParameters({ nodeId, parameters })); + } + dispatch(updateErrorDetails({ id: nodeId, clear: true })); + dispatch(setIsWorkflowDirty(true)); + if (reference) { + await refreshConnectionMetadata(nodeId, dispatch, getState as () => RootState, true); + } else { + const currentInputs = (getState() as RootState).operations.inputParameters[nodeId]; + const currentParameters = Object.values(currentInputs?.parameterGroups ?? {}).flatMap((group) => group.parameters); + const missingSchemaKeys = Object.entries(dependencies) + .filter( + ([key, dependency]) => + dependency.dependencyType === 'ApiSchema' && + !currentParameters.some((parameter) => parameter.parameterKey === key || parameter.info.dynamicParameterReference === key) + ) + .map(([key]) => key); + if (currentInputs && missingSchemaKeys.length) { + const originalOperation = state.workflow.operations[nodeId]; + const originalInputs = currentInputs.preservedConnectionInputs ?? (originalOperation as LogicAppsV2.ServiceProvider)?.inputs; + const definition = { + ...originalOperation, + type: operationInfo.type, + inputs: { + ...originalInputs, + serviceProviderConfiguration: { ...originalInputs?.serviceProviderConfiguration, connectionName: expression }, + }, + }; + const manifest = await getOperationManifest(operationInfo); + const customSwagger = await getCustomSwaggerIfNeeded(manifest.properties, definition); + const { inputs } = getInputParametersFromManifest(nodeId, operationInfo, manifest, undefined, customSwagger, definition); + const groups = { ...currentInputs.parameterGroups }; + for (const [groupId, group] of Object.entries(inputs.parameterGroups)) { + const missing = group.parameters.filter((parameter) => missingSchemaKeys.includes(parameter.parameterKey)); + if (missing.length) { + const current = groups[groupId] ?? { ...group, parameters: [], rawInputs: [] }; + groups[groupId] = { + ...current, + parameters: [...current.parameters, ...missing], + rawInputs: [...current.rawInputs, ...group.rawInputs.filter((input) => missingSchemaKeys.includes(input.key))], + }; + } + } + dispatch(updateNodeParameterGroups({ nodeId, parameterGroups: groups })); + } + } + } +); + export const closeConnectionsFlow = createAsyncThunk( 'closeConnectionsFlow', async ({ nodeId, panelMode }: { nodeId: string; panelMode?: PanelMode }, { dispatch }): Promise => { @@ -297,10 +410,20 @@ export const updateNodeConnectionAndProperties = async ( getState: () => RootState ): Promise => { const { nodeId } = payload; + const preserveInputs = isExpressionConnectionMapping(getState().connections.connectionsMapping[nodeId]); dispatch(changeConnectionMapping(payload)); dispatch(setIsWorkflowDirty(true)); + await refreshConnectionMetadata(nodeId, dispatch, getState, preserveInputs); +}; +const refreshConnectionMetadata = async ( + nodeId: string, + dispatch: Dispatch, + getState: () => RootState, + preserveInputs = false +): Promise => { const newState = getState() as RootState; + const previousGroups = newState.operations.inputParameters[nodeId]?.parameterGroups; const operationInfo = getRecordEntry(newState.operations.operationInfo, nodeId); const dependencies = getRecordEntry(newState.operations.dependencies, nodeId); const newlyAddedOperations = getRecordEntry(newState.workflow.newlyAddedOperations, nodeId); @@ -311,6 +434,12 @@ export const updateNodeConnectionAndProperties = async ( return; } + const hasManualSchemaInput = + preserveInputs && + Object.values(previousGroups ?? {}).some((group) => + group.parameters.some((parameter) => parameter.info.isDynamic && parameter.info.dynamicParameterReference === parameter.parameterKey) + ); + const inputSnapshot = hasManualSchemaInput ? await serializeOperation(newState, nodeId, { skipValidation: true }) : undefined; try { await updateDynamicDataInNode( nodeId, @@ -323,9 +452,50 @@ export const updateNodeConnectionAndProperties = async ( newState.tokens?.variables ?? {}, newState.workflowParameters?.definitions ?? {}, !!newState.tokens /* updateTokenMetadata */, - newlyAddedOperations ? undefined : operation + inputSnapshot ?? (newlyAddedOperations ? undefined : operation), + true, + !preserveInputs ); } finally { + if (preserveInputs && previousGroups) { + const groups = { ...getState().operations.inputParameters[nodeId]?.parameterGroups }; + for (const [groupId, previousGroup] of Object.entries(previousGroups)) { + const currentGroup = groups[groupId] ?? previousGroup; + const previousByKey = new Map(previousGroup.parameters.map((parameter) => [parameter.parameterKey, parameter])); + const currentKeys = new Set(currentGroup.parameters.map((parameter) => parameter.parameterKey)); + groups[groupId] = { + ...currentGroup, + parameters: [ + ...currentGroup.parameters.map((parameter) => { + const previous = previousByKey.get(parameter.parameterKey); + return previous + ? { + ...parameter, + value: previous.value, + preservedValue: previous.preservedValue, + editorViewModel: previous.editorViewModel, + } + : parameter; + }), + ...previousGroup.parameters.filter( + (parameter) => + !currentKeys.has(parameter.parameterKey) && + !( + parameter.info.dynamicParameterReference === parameter.parameterKey && + currentGroup.parameters.some((current) => current.info.dynamicParameterReference === parameter.parameterKey) + ) + ), + ], + }; + } + dispatch( + updateNodeParameterGroups({ + nodeId, + parameterGroups: groups, + ...(inputSnapshot ? { preservedConnectionInputs: { ...(inputSnapshot as LogicAppsV2.ServiceProvider).inputs } } : {}), + }) + ); + } dispatch(setIsWorkflowDirty(true)); } }; @@ -413,10 +583,14 @@ export const updateIdentityChangeInConnection = createAsyncThunk( const { nodeId, identity } = payload; const rootState = getState() as RootState; const userAssignedIdentity = identity !== Constants.SYSTEM_ASSIGNED_MANAGED_IDENTITY ? identity : undefined; + const reference = getConnectionReference(rootState.connections, nodeId); + if (!reference) { + return; + } const { api: { id: connectorId }, connection: { id: connectionId }, - } = getConnectionReference(rootState.connections, nodeId); + } = reference; const connector = await getConnector(connectorId); const connection = await getConnection(connectionId, connectorId); @@ -493,12 +667,12 @@ export const autoCreateConnectionIfPossible = async (payload: { } }; -export async function getConnectionsMappingForNodes(deserializedWorkflow: DeserializedWorkflow): Promise> { +export async function getConnectionsMappingForNodes(deserializedWorkflow: DeserializedWorkflow): Promise { const { actionData, nodesMetadata } = deserializedWorkflow; - let connectionsMapping: Record = {}; + let connectionsMapping: ConnectionMapping = {}; const operationManifestService = OperationManifestService(); - const tasks: Promise | undefined>[] = []; + const tasks: Promise[] = []; for (const [nodeId, operation] of Object.entries(actionData)) { const isTrigger = getRecordEntry(nodesMetadata, nodeId)?.isTrigger ?? false; @@ -517,8 +691,14 @@ export const getConnectionMappingForNode = ( nodeId: string, isTrigger: boolean, operationManifestService: IOperationManifestService -): Promise | undefined> => { +): Promise => { try { + if (isServiceProviderOperation(operation.type)) { + const connectionName = (operation as LogicAppsV2.ServiceProvider).inputs?.serviceProviderConfiguration?.connectionName; + return Promise.resolve( + typeof connectionName === 'string' ? { [nodeId]: getServiceProviderConnectionMapping(connectionName) } : undefined + ); + } if (operationManifestService.isSupported(operation.type, operation.kind)) { return getManifestBasedConnectionMapping(nodeId, isTrigger, operation); } @@ -648,7 +828,11 @@ export async function getManifestBasedConnectionMapping( nodeId: string, isTrigger: boolean, operationDefinition: LogicAppsV2.OperationDefinition -): Promise | undefined> { +): Promise { + if (isServiceProviderOperation(operationDefinition.type)) { + const connectionName = (operationDefinition as LogicAppsV2.ServiceProvider).inputs?.serviceProviderConfiguration?.connectionName; + return typeof connectionName === 'string' ? { [nodeId]: getServiceProviderConnectionMapping(connectionName) } : undefined; + } try { const { connectorId, operationId } = await getOperationInfo(nodeId, operationDefinition, isTrigger); const operationManifest = await getOperationManifest({ diff --git a/libs/designer-v2/src/lib/core/actions/bjsworkflow/copypaste.ts b/libs/designer-v2/src/lib/core/actions/bjsworkflow/copypaste.ts index f5dbf9b1dec..66aea85edc7 100644 --- a/libs/designer-v2/src/lib/core/actions/bjsworkflow/copypaste.ts +++ b/libs/designer-v2/src/lib/core/actions/bjsworkflow/copypaste.ts @@ -1,6 +1,16 @@ -import type { ConnectionReference, ReferenceKey } from '../../../common/models/workflow'; +import { isExpressionConnectionMapping, type ConnectionMapping } from '../../../common/models/workflow'; +import { + getServiceProviderConnectionMapping, + remapConnectionExpression, + remapConnectionExpressionValue, +} from '../../utils/connectors/connectionExpression'; import { getTriggerNodeId, setFocusNode, type RootState } from '../..'; -import { initCopiedConnectionMap, initScopeCopiedConnections } from '../../state/connection/connectionSlice'; +import { + initCopiedConnectionMap, + initScopeCopiedConnections, + setNodeConnectionMapping, + type CopiedConnectionData, +} from '../../state/connection/connectionSlice'; import type { NodeData, NodeOperation } from '../../state/operation/operationMetadataSlice'; import { initializeNodes, initializeOperationInfo } from '../../state/operation/operationMetadataSlice'; import type { RelationshipIds } from '../../state/panel/panelTypes'; @@ -49,7 +59,18 @@ export const copyOperation = createAsyncThunk('copyOperation', async (payload: C const nodeData = getNodeOperationData(state.operations, nodeId); const nodeOperationInfo = getRecordEntry(state.operations.operationInfo, nodeId); const nodeComment = getRecordEntry(state.workflow.operations, nodeId)?.description; - const nodeConnectionData = getRecordEntry(state.connections.connectionsMapping, nodeId); + const mapping = getRecordEntry(state.connections.connectionsMapping, nodeId); + const nodeConnectionData = isExpressionConnectionMapping(mapping) + ? { ...mapping, expression: remapConnectionExpression(mapping.expression, state.workflow.idReplacements) } + : mapping; + if (isExpressionConnectionMapping(mapping) && nodeData.nodeInputs) { + nodeData.nodeInputs = { + ...nodeData.nodeInputs, + preservedConnectionInputs: + nodeData.nodeInputs.preservedConnectionInputs ?? + (getRecordEntry(state.workflow.operations, nodeId) as LogicAppsV2.ServiceProvider)?.inputs, + }; + } const nodeTokenData = getRecordEntry(state.tokens.outputTokens, nodeId); const clipboardItem = JSON.stringify({ @@ -71,7 +92,7 @@ export const copyOperation = createAsyncThunk('copyOperation', async (payload: C }); export const copyScopeOperation = createAsyncThunk('copyScopeOperation', async (payload: CopyOperationPayload, { getState }) => { - batch(async () => { + await (async () => { let { nodeId: scopeNodeId } = payload; if (!scopeNodeId) { throw new Error('Scope Node does not exist'); @@ -86,13 +107,23 @@ export const copyScopeOperation = createAsyncThunk('copyScopeOperation', async ( }); const allActionNames = getAllActionNames({ [scopeNodeId]: serializedOperation as ActionDefinition }); - const allConnectionData: Record = {}; + const allConnectionData: Record = {}; const staticResults: Record = {}; allActionNames.forEach((actionName) => { - const connectionReference = getConnectionReferenceForNodeId(state.connections, actionName); + const originalId = + Object.keys(state.workflow.idReplacements).find((id) => state.workflow.idReplacements[id] === actionName) ?? actionName; + const connectionReference = getConnectionReferenceForNodeId(state.connections, originalId); if (connectionReference) { - allConnectionData[actionName] = connectionReference; + allConnectionData[actionName] = connectionReference.mapping + ? { + ...connectionReference, + mapping: { + ...connectionReference.mapping, + expression: remapConnectionExpression(connectionReference.mapping.expression, state.workflow.idReplacements), + }, + } + : connectionReference; } const staticResult = getStaticResultForNodeId(state.staticResults, actionName); @@ -113,7 +144,7 @@ export const copyScopeOperation = createAsyncThunk('copyScopeOperation', async ( } else { localStorage.setItem(LOCAL_STORAGE_KEYS.CLIPBOARD, clipboardItem); } - }); + })(); }); type CopyOperationsPayload = { @@ -125,7 +156,18 @@ const buildActionClipboardEntry = (state: RootState, nodeId: string) => { const nodeData = getNodeOperationData(state.operations, nodeId); const nodeOperationInfo = getRecordEntry(state.operations.operationInfo, nodeId); const nodeComment = getRecordEntry(state.workflow.operations, nodeId)?.description; - const nodeConnectionData = getRecordEntry(state.connections.connectionsMapping, nodeId); + const mapping = getRecordEntry(state.connections.connectionsMapping, nodeId); + const nodeConnectionData = isExpressionConnectionMapping(mapping) + ? { ...mapping, expression: remapConnectionExpression(mapping.expression, state.workflow.idReplacements) } + : mapping; + if (isExpressionConnectionMapping(mapping) && nodeData.nodeInputs) { + nodeData.nodeInputs = { + ...nodeData.nodeInputs, + preservedConnectionInputs: + nodeData.nodeInputs.preservedConnectionInputs ?? + (getRecordEntry(state.workflow.operations, nodeId) as LogicAppsV2.ServiceProvider)?.inputs, + }; + } const nodeTokenData = getRecordEntry(state.tokens.outputTokens, nodeId); return { @@ -149,13 +191,23 @@ const buildScopeClipboardEntry = async (state: RootState, scopeNodeId: string) = }); const allActionNames = getAllActionNames({ [normalizedScopeId]: serializedOperation as ActionDefinition }); - const allConnectionData: Record = {}; + const allConnectionData: Record = {}; const staticResults: Record = {}; allActionNames.forEach((actionName) => { - const connectionReference = getConnectionReferenceForNodeId(state.connections, actionName); + const originalId = + Object.keys(state.workflow.idReplacements).find((id) => state.workflow.idReplacements[id] === actionName) ?? actionName; + const connectionReference = getConnectionReferenceForNodeId(state.connections, originalId); if (connectionReference) { - allConnectionData[actionName] = connectionReference; + allConnectionData[actionName] = connectionReference.mapping + ? { + ...connectionReference, + mapping: { + ...connectionReference.mapping, + expression: remapConnectionExpression(connectionReference.mapping.expression, state.workflow.idReplacements), + }, + } + : connectionReference; } const staticResult = getStaticResultForNodeId(state.staticResults, actionName); @@ -260,7 +312,7 @@ interface PasteOperationPayload { nodeData: NodeData; nodeTokenData: NodeTokens; operationInfo: NodeOperation; - connectionData?: ReferenceKey; + connectionData?: ConnectionMapping[string]; comment?: string; isParallelBranch?: boolean; } @@ -287,12 +339,14 @@ export const pasteOperation = createAsyncThunk('pasteOperation', async (payload: dispatch(setFocusNode(nodeId)); dispatch(initializeOperationInfo({ id: nodeId, ...operationInfo })); - await initializeOperationDetails(nodeId, operationInfo, getState as () => RootState, dispatch); + if (!isExpressionConnectionMapping(connectionData)) { + await initializeOperationDetails(nodeId, operationInfo, getState as () => RootState, dispatch); + } // replace new nodeId if there exists a copy of the copied node dispatch(initializeNodes({ nodes: [{ ...nodeData, id: nodeId }] })); - const updatedTokens = nodeTokenData.tokens.map((token) => { + const updatedTokens = (nodeTokenData?.tokens ?? []).map((token) => { // Modify the actionName to a unique value return { ...token, @@ -327,7 +381,7 @@ interface PasteScopeOperationPayload { relationshipIds: RelationshipIds; nodeId: string; serializedValue: LogicAppsV2.OperationDefinition | null; - allConnectionData: Record; + allConnectionData: Record; staticResults: Record; upstreamNodeIds: string[]; isParallelBranch?: boolean; @@ -374,7 +428,43 @@ export const pasteScopeOperation = createAsyncThunk( const scopeParentNodeId = graphId && graphId !== 'root' ? graphId : undefined; actionNodesMetadata[nodeId] = { ...actionNodesMetadata[actionId], isRoot: false, parentNodeId: scopeParentNodeId, graphId }; if (Object.keys(allConnectionData).length > 0) { - dispatch(initScopeCopiedConnections(replaceIdsOfExistingNodes(allConnectionData, pasteParams.renamedNodes))); + const copiedConnections = replaceIdsOfExistingNodes(allConnectionData, pasteParams.renamedNodes); + for (const [id, data] of Object.entries(copiedConnections)) { + if (data.mapping) { + const action = actions[id]; + const inputs = (action as LogicAppsV2.ServiceProvider)?.inputs; + if (inputs) { + actions[id] = { ...action, inputs: remapConnectionExpressionValue(inputs, pasteParams.renamedNodes) }; + } + copiedConnections[id] = { + ...data, + mapping: { ...data.mapping, expression: remapConnectionExpression(data.mapping.expression, pasteParams.renamedNodes) }, + }; + } + } + dispatch(initScopeCopiedConnections(copiedConnections)); + } + for (const [id, action] of Object.entries(actions)) { + const connectionName = (action as LogicAppsV2.ServiceProvider).inputs?.serviceProviderConfiguration?.connectionName; + if ( + action.type.toLowerCase() === 'serviceprovider' && + typeof connectionName === 'string' && + !(getState() as RootState).connections.connectionsMapping[id] + ) { + const mapping = getServiceProviderConnectionMapping(connectionName); + if (isExpressionConnectionMapping(mapping)) { + dispatch( + setNodeConnectionMapping({ + nodeId: id, + mapping: { ...mapping, expression: remapConnectionExpression(mapping.expression, pasteParams.renamedNodes) }, + }) + ); + actions[id] = { + ...action, + inputs: remapConnectionExpressionValue((action as LogicAppsV2.ServiceProvider).inputs, pasteParams.renamedNodes), + }; + } + } } if (Object.keys(staticResults).length > 0) { dispatch(initScopeCopiedStaticResultProperties(replaceIdsOfExistingNodes(staticResults, pasteParams.renamedNodes))); diff --git a/libs/designer-v2/src/lib/core/actions/bjsworkflow/initialize.ts b/libs/designer-v2/src/lib/core/actions/bjsworkflow/initialize.ts index e48f4cc4969..01358cb3067 100644 --- a/libs/designer-v2/src/lib/core/actions/bjsworkflow/initialize.ts +++ b/libs/designer-v2/src/lib/core/actions/bjsworkflow/initialize.ts @@ -1,7 +1,9 @@ import type { CustomCodeFileNameMapping } from '../../..'; import Constants from '../../../common/constants'; import type { ConnectionReferences, WorkflowParameter } from '../../../common/models/workflow'; -import { ImpersonationSource } from '../../../common/models/workflow'; +import { ImpersonationSource, isExpressionConnectionMapping } from '../../../common/models/workflow'; +import { getServiceProviderConnectionMapping } from '../../utils/connectors/connectionExpression'; +import { getDynamicInputParameterFromDynamicParameter } from '../../utils/parameters/dynamicdata'; import type { WorkflowNode } from '../../parsers/models/workflowNode'; import { getConnectorWithSwagger, getSwaggerFromEndpoint } from '../../queries/connections'; import { getOperationManifest } from '../../queries/operation'; @@ -102,6 +104,7 @@ import { AssertionErrorCode, getIntl, isObject, + isServiceProviderOperation, } from '@microsoft/logic-apps-shared'; import type { ParameterInfo } from '@microsoft/designer-ui'; import type { Dispatch } from '@reduxjs/toolkit'; @@ -195,6 +198,17 @@ export const getInputParametersFromManifest = ( } } + const connectionName = stepDefinition?.inputs?.serviceProviderConfiguration?.connectionName; + if ( + isServiceProviderOperation(operationInfo.type) && + typeof connectionName === 'string' && + isExpressionConnectionMapping(getServiceProviderConnectionMapping(connectionName)) + ) { + // schema.required lists child properties; it must not make an optional manual root required. + primaryInputParametersInArray = primaryInputParametersInArray.map((parameter) => + parameter.dynamicSchema ? { ...getDynamicInputParameterFromDynamicParameter(parameter), required: parameter.required } : parameter + ); + } const shouldEncodeBasedOnMetadata = shouldEncodeParameterValueForOperationBasedOnMetadata(operationInfo); const allParametersAsArray = toParameterInfoMap(primaryInputParametersInArray, stepDefinition, shouldEncodeBasedOnMetadata); const dynamicInput = primaryInputParametersInArray.find((parameter) => parameter.dynamicSchema); 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 517c5d89c42..07d59771756 100644 --- a/libs/designer-v2/src/lib/core/actions/bjsworkflow/operationdeserializer.ts +++ b/libs/designer-v2/src/lib/core/actions/bjsworkflow/operationdeserializer.ts @@ -1,6 +1,7 @@ import { isCustomCodeParameter } from '@microsoft/designer-ui'; import type { CustomCodeFileNameMapping } from '../../..'; import Constants from '../../../common/constants'; +import { getServiceProviderConnectionMapping } from '../../utils/connectors/connectionExpression'; import type { ConnectionReference, ConnectionReferences, WorkflowParameter } from '../../../common/models/workflow'; import type { DeserializedWorkflow } from '../../parsers/BJSWorkflow/BJSDeserializer'; import type { WorkflowNode } from '../../parsers/models/workflowNode'; @@ -429,6 +430,15 @@ export const initializeOperationDetailsForManifest = async ( operation ); + const serviceProviderInputs = (operation as LogicAppsV2.ServiceProvider).inputs; + if ( + operation.type.toLowerCase() === 'serviceprovider' && + typeof serviceProviderInputs?.serviceProviderConfiguration?.connectionName === 'string' && + typeof getServiceProviderConnectionMapping(serviceProviderInputs.serviceProviderConfiguration.connectionName) === 'object' + ) { + nodeInputs.preservedConnectionInputs = serviceProviderInputs; + } + if (isTrigger) { await updateCallbackUrlInInputs(nodeId, nodeOperationInfo, nodeInputs); await updateAgentUrlInInputs(nodeOperationInfo, nodeInputs); @@ -818,6 +828,13 @@ const updateDynamicDataForValidConnection = async ( operation: LogicAppsV2.ActionDefinition | LogicAppsV2.TriggerDefinition, isFreshCreatedAgent: boolean ): Promise => { + const mapping = getState().connections.connectionsMapping[nodeId]; + if (mapping && typeof mapping !== 'string') { + // Runtime selection does not require a design-time connection. Keep raw inputs intact. + if (!reference) { + return; + } + } const isValidConnection = await isConnectionReferenceValid(operationInfo, reference); if (isValidConnection) { @@ -838,7 +855,7 @@ const updateDynamicDataForValidConnection = async ( true /* updateTokenMetadata */, operation ); - } else if (!isFreshCreatedAgent) { + } else if (!isFreshCreatedAgent && (!mapping || typeof mapping === 'string')) { LoggerService().log({ level: LogEntryLevel.Warning, area: 'OperationDeserializer:UpdateDynamicData', diff --git a/libs/designer-v2/src/lib/core/actions/bjsworkflow/serializer.ts b/libs/designer-v2/src/lib/core/actions/bjsworkflow/serializer.ts index 13726411030..f982795e4fe 100644 --- a/libs/designer-v2/src/lib/core/actions/bjsworkflow/serializer.ts +++ b/libs/designer-v2/src/lib/core/actions/bjsworkflow/serializer.ts @@ -1,5 +1,16 @@ import Constants, { MCP_AUTH_PROPERTY_KEYS } from '../../../common/constants'; -import type { ConnectionReferences, Workflow, WorkflowParameter } from '../../../common/models/workflow'; +import { + isExpressionConnectionMapping, + type ConnectionReferences, + type Workflow, + type WorkflowParameter, +} from '../../../common/models/workflow'; +import { + isConnectionExpressionValid, + remapConnectionExpression, + remapConnectionExpressionValue, + serializeServiceProviderConnectionKey, +} from '../../utils/connectors/connectionExpression'; import type { WorkflowNode } from '../../parsers/models/workflowNode'; import { getConnectorWithSwagger } from '../../queries/connections'; import { getOperationManifest } from '../../queries/operation'; @@ -74,11 +85,18 @@ export const serializeWorkflow = async (rootState: RootState, options?: Serializ if (!options?.skipValidation) { const intl = getIntl(); - const operationsWithConnectionErrors = Object.entries(rootState.operations.errors).filter( - ([_id, errors]) => !!errors[ErrorLevel.Connection] - ); + const operationsWithConnectionErrors = Object.entries(rootState.operations.errors) + .filter( + ([id, errors]) => !isExpressionConnectionMapping(rootState.connections.connectionsMapping[id]) && !!errors[ErrorLevel.Connection] + ) + .map(([id]) => id); + for (const [id, mapping] of Object.entries(rootState.connections.connectionsMapping)) { + if (isExpressionConnectionMapping(mapping) && !isConnectionExpressionValid(mapping.expression)) { + operationsWithConnectionErrors.push(id); + } + } if (operationsWithConnectionErrors.length > 0) { - const invalidNodes = operationsWithConnectionErrors.map(([id]) => id).join(', '); + const invalidNodes = operationsWithConnectionErrors.join(', '); throw new SerializationException( SerializationErrorCode.INVALID_CONNECTIONS, intl.formatMessage( @@ -142,9 +160,12 @@ export const serializeWorkflow = async (rootState: RootState, options?: Serializ } const { connectionsMapping, connectionReferences: referencesObject } = rootState.connections; + const preservedReferences = Object.values(connectionsMapping ?? {}).some(isExpressionConnectionMapping) + ? filterRecord(referencesObject, (_key, reference) => /(^|\/)serviceProviders\//i.test(reference.api.id)) + : {}; const connectionReferences = Object.keys(connectionsMapping ?? {}).reduce((references: ConnectionReferences, nodeId: string) => { const referenceKey = getRecordEntry(connectionsMapping, nodeId); - if (!referenceKey) { + if (typeof referenceKey !== 'string' || !referenceKey) { return references; } @@ -169,7 +190,7 @@ export const serializeWorkflow = async (rootState: RootState, options?: Serializ references[referenceKey] = reference; return references; - }, {}); + }, preservedReferences); const parameters = getWorkflowParameters(filterRecord(rootState.workflowParameters.definitions, (key, _) => key !== '')) ?? {}; @@ -355,7 +376,13 @@ export const serializeOperation = async ( nodeInputs?.dynamicLoadStatus !== undefined && nodeInputs.dynamicLoadStatus !== DynamicLoadStatus.SUCCEEDED; const hasStash = !!nodeInputs?.stashedDynamicParameterValues?.length; const hasDynamicParamsInGroups = getOperationInputParameters(nodeInputs as NodeInputs).some((p) => p.info.isDynamic); - if ((hasDynamicInputsError || nodeExpectsDynamicInputs) && !hasStash && !hasDynamicParamsInGroups) { + if ( + !isExpressionConnectionMapping(rootState.connections.connectionsMapping[operationId]) && + !nodeInputs?.preservedConnectionInputs && + (hasDynamicInputsError || nodeExpectsDynamicInputs) && + !hasStash && + !hasDynamicParamsInGroups + ) { const originalDef = getRecordEntry(rootState.workflow.operations, operationId); if (originalDef && 'inputs' in originalDef && originalDef.inputs && 'inputs' in serializedOperation) { serializedOperation = { @@ -459,7 +486,31 @@ const serializeManifestBasedOperation = async (rootState: RootState, operationId const inputsToSerialize = getOperationInputsToSerialize(rootState, operationId); const nodeSettings = getRecordEntry(rootState.operations.settings, operationId) ?? {}; const nodeStaticResults = getRecordEntry(rootState.operations.staticResults, operationId) ?? ({} as NodeStaticResults); - const inputPathValue = serializeParametersFromManifest(inputsToSerialize, manifest); + let inputPathValue = serializeParametersFromManifest(inputsToSerialize, manifest); + const nodeInputs = getRecordEntry(rootState.operations.inputParameters, operationId); + if (isExpressionConnectionMapping(rootState.connections.connectionsMapping[operationId]) || nodeInputs?.preservedConnectionInputs) { + const rawInputs: Record = { + ...clone( + nodeInputs?.preservedConnectionInputs ?? + (getRecordEntry(rootState.workflow.operations, operationId) as LogicAppsV2.ServiceProvider)?.inputs ?? + {} + ), + }; + delete rawInputs.serviceProviderConfiguration; + for (const input of inputsToSerialize) { + const segments = parseEx(input.parameterKey); + if (segments[0]?.value === 'inputs') { + deleteObjectProperty(rawInputs, [ + ...(manifest.properties.inputsLocation?.slice(1) ?? []), + ...segments.slice(2).map((segment) => String(segment.value)), + ]); + } + } + inputPathValue = merge( + remapConnectionExpressionValue(rawInputs, idReplacements, getParameterNameReplacements(rootState)), + inputPathValue + ); + } // For FoundryAgentServiceV2, strip system messages — instructions live on the Foundry agent definition if (inputPathValue?.parameters?.agentModelType === 'FoundryAgentServiceV2' && Array.isArray(inputPathValue?.parameters?.messages)) { @@ -566,7 +617,7 @@ const serializeConsumptionBuiltInMcpOperation = async (rootState: RootState, nod // incomplete Connection object that the backend would reject. const existingConnectionInput = (operationFromWorkflow as any)?.inputs?.Connection; const referenceKey = getRecordEntry(rootState.connections.connectionsMapping, nodeId); - const connectionReference = referenceKey ? getRecordEntry(rootState.connections.connectionReferences, referenceKey) : undefined; + const connectionReference = typeof referenceKey === 'string' ? rootState.connections.connectionReferences[referenceKey] : undefined; const connectionId = connectionReference?.connection?.id; // All auth-related property keys that can appear in parameterValues @@ -999,6 +1050,13 @@ interface ServiceProviderConnectionConfigInfo { }; } +const getParameterNameReplacements = (rootState: RootState): Record => + Object.fromEntries( + Object.entries(rootState.workflowParameters?.definitions ?? {}) + .filter(([id, definition]) => definition.name && definition.name !== id) + .map(([id, definition]) => [id, definition.name]) + ); + interface AgentConnectionInfo { modelConfigurations: { model1: { @@ -1039,7 +1097,8 @@ const serializeHost = ( const intl = getIntl(); const { referenceKeyFormat } = manifest.properties.connectionReference; - const referenceKey = getRecordEntry(rootState.connections.connectionsMapping, nodeId) ?? ('' as any); + const mapping = getRecordEntry(rootState.connections.connectionsMapping, nodeId); + const referenceKey = typeof mapping === 'string' ? mapping : ''; const { connectorId, operationId } = getRecordEntry(rootState.operations.operationInfo, nodeId) ?? ({} as any); switch (referenceKeyFormat) { @@ -1078,7 +1137,9 @@ const serializeHost = ( case ConnectionReferenceKeyFormat.ServiceProvider: return { serviceProviderConfiguration: { - connectionName: referenceKey, + connectionName: isExpressionConnectionMapping(mapping) + ? remapConnectionExpression(mapping.expression, rootState.workflow.idReplacements, getParameterNameReplacements(rootState)) + : serializeServiceProviderConnectionKey(referenceKey), operationId, serviceProviderId: connectorId, }, 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 4a77822343d..ce8e7671962 100644 --- a/libs/designer-v2/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts +++ b/libs/designer-v2/src/lib/core/actions/bjsworkflow/updateNodeFromCodeView.ts @@ -1,9 +1,9 @@ import { getTriggerNodeId, type RootState } from '../..'; import type { LogicAppsV2 } from '@microsoft/logic-apps-shared'; -import { OperationManifestService, getRecordEntry } from '@microsoft/logic-apps-shared'; +import { OperationManifestService, equals, getRecordEntry } from '@microsoft/logic-apps-shared'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { setIsPanelLoading } from '../../state/panel/panelSlice'; -import { initializeNodes } from '../../state/operation/operationMetadataSlice'; +import { initializeNodes, updateErrorDetails } from '../../state/operation/operationMetadataSlice'; import { deinitializeTokensAndVariables, initializeTokensAndVariables } from '../../state/tokens/tokensSlice'; import { replaceOperationDefinition } from '../../state/workflow/workflowSlice'; import { isManagedMcpOperation } from '../../state/workflow/helper'; @@ -22,6 +22,9 @@ import { type NodeDataWithOperationMetadata, } from './operationdeserializer'; import { initializeOperationDetailsForSwagger } from '../../utils/swagger/operation'; +import { getConnectionMappingForNode } from './connections'; +import { setNodeConnectionMapping } from '../../state/connection/connectionSlice'; +import { isExpressionConnectionMapping } from '../../../common/models/workflow'; export interface UpdateNodeFromCodeViewPayload { nodeId: string; @@ -61,6 +64,23 @@ export const updateNodeFromCodeView = createAsyncThunk( const triggerNodeId = getTriggerNodeId(updatedState.workflow); const operationManifestService = OperationManifestService(); + const importedMapping = await getConnectionMappingForNode(serializedOperation, nodeId, isTrigger, operationManifestService); + const previousMapping = state.connections.connectionsMapping[nodeId]; + let mapping = importedMapping?.[nodeId] ?? null; + if (isExpressionConnectionMapping(mapping) && isExpressionConnectionMapping(previousMapping)) { + const key = previousMapping.designTimeReferenceKey; + const reference = key && Object.hasOwn(references, key) ? references[key] : undefined; + const configuration = (serializedOperation as LogicAppsV2.ServiceProvider).inputs?.serviceProviderConfiguration; + const serviceProviderId = + configuration && 'serviceProviderId' in configuration && typeof configuration.serviceProviderId === 'string' + ? configuration.serviceProviderId + : undefined; + if (reference && equals(reference.api.id, serviceProviderId, true)) { + mapping = { ...mapping, designTimeReferenceKey: key || undefined }; + } + } + dispatch(setNodeConnectionMapping({ nodeId, mapping })); + dispatch(updateErrorDetails({ id: nodeId, clear: true })); let nodeData: NodeDataWithOperationMetadata[] | undefined; if (isManagedMcpOperation(serializedOperation)) { nodeData = await initializeOperationDetailsForManagedMcpServer(nodeId, serializedOperation, references, workflowKind, dispatch); diff --git a/libs/designer-v2/src/lib/core/configuretemplate/utils/helper.ts b/libs/designer-v2/src/lib/core/configuretemplate/utils/helper.ts index 9975eefe5d4..86517e5aa81 100644 --- a/libs/designer-v2/src/lib/core/configuretemplate/utils/helper.ts +++ b/libs/designer-v2/src/lib/core/configuretemplate/utils/helper.ts @@ -85,7 +85,10 @@ export const getConnectionMappingInDefinition = async ( const workflow = Deserialize(definition, /* runInstance */ null); const mapping = await getConnectionsMappingForNodes(workflow); return Object.keys(mapping).reduce((result: Record, operationId: string) => { - result[`${workflowId}${delimiter}${operationId}`] = mapping[operationId]; + const referenceKey = mapping[operationId]; + if (typeof referenceKey === 'string') { + result[`${workflowId}${delimiter}${operationId}`] = referenceKey; + } return result; }, {}); } catch (error: any) { diff --git a/libs/designer-v2/src/lib/core/index.ts b/libs/designer-v2/src/lib/core/index.ts index f16fb47caa4..fca26523639 100644 --- a/libs/designer-v2/src/lib/core/index.ts +++ b/libs/designer-v2/src/lib/core/index.ts @@ -8,6 +8,7 @@ export { mcpStore } from './state/mcp/store'; export { templateStore } from './state/templates/store'; export { useConnectionMapping, + useNodeConnectionMapping, useConnectionRefs, useIsOperationMissingConnection, } from './state/connection/connectionSelector'; @@ -106,7 +107,7 @@ export { } from './utils/card'; export { addOperation } from './actions/bjsworkflow/add'; export { addConnectorAsOperation } from './actions/bjsworkflow/agent'; -export { updateNodeConnection } from './actions/bjsworkflow/connections'; +export { updateNodeConnection, updateNodeConnectionExpression } from './actions/bjsworkflow/connections'; export { storeStateToUndoRedoHistory, onUndoClick, onRedoClick } from './actions/bjsworkflow/undoRedo'; export { initializeNotes } from './state/notes/notesSlice'; export { useCanUndo, useCanRedo } from './state/undoRedo/undoRedoSelectors'; diff --git a/libs/designer-v2/src/lib/core/mcp/utils/serializer.ts b/libs/designer-v2/src/lib/core/mcp/utils/serializer.ts index 020a5ef63b8..326a16cc3bc 100644 --- a/libs/designer-v2/src/lib/core/mcp/utils/serializer.ts +++ b/libs/designer-v2/src/lib/core/mcp/utils/serializer.ts @@ -62,7 +62,8 @@ export const serializeMcpWorkflows = async ( const logicAppId = getStandardLogicAppId(subscriptionId, resourceGroup, logicAppName); const workflows: Record = {}; const promises = Object.keys(operationInfo).map(async (nodeId) => { - const referenceName = connectionState.connectionsMapping[nodeId] as string; + const mapping = connectionState.connectionsMapping[nodeId]; + const referenceName = typeof mapping === 'string' ? mapping : ''; return getOperationDefinitionAndTriggerInputs(referenceName, operationInfo[nodeId], inputParameters[nodeId], settings[nodeId]); }); @@ -276,8 +277,8 @@ const getConnectionsDataToSerialize = async ( ): Promise<{ connectionsData: ConnectionsData | undefined; references: string[] }> => { const { connectionReferences, connectionsMapping } = connectionState; const queryClient = getReactQueryClient(); - const referencesToSerialize = Object.values(connectionsMapping).reduce((result: string[], referenceKey: string | null) => { - if (referenceKey && !result.includes(referenceKey)) { + const referencesToSerialize = Object.values(connectionsMapping).reduce((result: string[], referenceKey) => { + if (typeof referenceKey === 'string' && referenceKey && !result.includes(referenceKey)) { result.push(referenceKey); } return result; diff --git a/libs/designer-v2/src/lib/core/queries/connections.ts b/libs/designer-v2/src/lib/core/queries/connections.ts index d188b6fd5f3..327a9584305 100644 --- a/libs/designer-v2/src/lib/core/queries/connections.ts +++ b/libs/designer-v2/src/lib/core/queries/connections.ts @@ -1,5 +1,5 @@ import { getReactQueryClient } from '../ReactQueryProvider'; -import { ConnectionService, SwaggerParser, equals, cleanResourceId } from '@microsoft/logic-apps-shared'; +import { ConnectionService, SwaggerParser, equals, cleanResourceId, isTemplateExpression } from '@microsoft/logic-apps-shared'; import type { Connection, Connector } from '@microsoft/logic-apps-shared'; import { useMemo } from 'react'; import type { UseQueryResult } from '@tanstack/react-query'; @@ -8,6 +8,7 @@ import { getConnector, getSwagger } from './operation'; const connectionKey = 'connections'; const allConnectionsKey = 'allConnections'; +export const getConnectionQueryKey = (id = ''): string => (/(^|\/)serviceProviders\//i.test(id) ? id : id.toLowerCase()); export interface ConnectorWithParsedSwagger { connector: Connector; @@ -79,7 +80,9 @@ export const useConnectionById = (connectionId: string, connectorId: string) => }; } - const foundConnection = (connections ?? []).find((connection: any) => equals(connection.id, connectionId)); + const foundConnection = (connections ?? []).find( + (connection) => getConnectionQueryKey(connection.id) === getConnectionQueryKey(connectionId) + ); return { isLoading, result: foundConnection ?? connection, @@ -112,11 +115,17 @@ export const getConnectionsForConnector = async (connectorId: string) => { }; export const getConnection = async (_connectionId: string, _connectorId: string, fetchResourceIfNeeded = false) => { + if (!_connectionId || isTemplateExpression(_connectionId)) { + return null; + } const connectionId = cleanResourceId(_connectionId); const connectorId = cleanResourceId(_connectorId); const connections = await getConnectionsForConnector(connectorId); - const connection = connections?.find((connection) => equals(connection.id, connectionId)); - return (!connection && fetchResourceIfNeeded ? getConnectionFromResource(connectionId) : connection) ?? null; + const isServiceProvider = /(^|\/)serviceProviders\//i.test(connectorId); + const connection = connections?.find((connection) => + isServiceProvider ? connection.id === connectionId : equals(connection.id, connectionId) + ); + return (!connection && fetchResourceIfNeeded && !isServiceProvider ? getConnectionFromResource(connectionId) : connection) ?? null; }; export const getUniqueConnectionName = async (connectorId: string, existingKeys: string[] = []): Promise => { @@ -126,7 +135,7 @@ export const getUniqueConnectionName = async (connectorId: string, existingKeys: }; export const useConnectionResource = (_connectionId: string) => { - const connectionId = cleanResourceId(_connectionId)?.toLowerCase(); + const connectionId = !_connectionId || isTemplateExpression(_connectionId) ? '' : getConnectionQueryKey(cleanResourceId(_connectionId)); return useQuery( ['connection', connectionId], async () => { @@ -134,7 +143,7 @@ export const useConnectionResource = (_connectionId: string) => { return result ?? null; }, { - enabled: !!_connectionId, + enabled: !!connectionId, refetchOnMount: false, } ); @@ -142,7 +151,7 @@ export const useConnectionResource = (_connectionId: string) => { const getConnectionFromResource = async (connectionId: string) => { const queryClient = getReactQueryClient(); - return queryClient.fetchQuery(['connection', connectionId?.toLowerCase()], async () => { + return queryClient.fetchQuery(['connection', getConnectionQueryKey(connectionId)], async () => { const result = await ConnectionService().getConnection(connectionId); return result ?? null; }); diff --git a/libs/designer-v2/src/lib/core/queries/connector.ts b/libs/designer-v2/src/lib/core/queries/connector.ts index a3638edfe0c..4e2940b4acd 100644 --- a/libs/designer-v2/src/lib/core/queries/connector.ts +++ b/libs/designer-v2/src/lib/core/queries/connector.ts @@ -1,4 +1,5 @@ import { getReactQueryClient } from '../ReactQueryProvider'; +import { getConnectionQueryKey } from './connections'; import type { ListDynamicValue, ManagedIdentityRequestProperties, @@ -37,7 +38,7 @@ export const getLegacyDynamicValues = async ( const response = await queryClient.fetchQuery( [ 'legacydynamicValues', - connectionId.toLowerCase(), + getConnectionQueryKey(connectionId), connectorId.toLowerCase(), extension.operationId?.toLowerCase(), getParametersKey(parameters).toLowerCase(), @@ -115,7 +116,7 @@ export const getListDynamicValues = async ( return queryClient.fetchQuery( [ 'listdynamicvalues', - (connectionId ?? '').toLowerCase(), + getConnectionQueryKey(connectionId), connectorId.toLowerCase(), operationId.toLowerCase(), dynamicState.operationId?.toLowerCase(), @@ -139,7 +140,7 @@ export const getLegacyDynamicSchema = async ( const response = await queryClient.fetchQuery( [ 'legacydynamicschema', - connectionId.toLowerCase(), + getConnectionQueryKey(connectionId), connectorId.toLowerCase(), extension.operationId?.toLowerCase(), getParametersKey(parameters).toLowerCase(), @@ -168,7 +169,7 @@ export const getDynamicSchemaProperties = async ( return queryClient.fetchQuery( [ 'dynamicschemaproperties', - (connectionId ?? '').toLowerCase(), + getConnectionQueryKey(connectionId), connectorId.toLowerCase(), operationId.toLowerCase(), dynamicState.extension.operationId?.toLowerCase(), @@ -193,7 +194,7 @@ export const getLegacyDynamicTreeItems = async ( const response = await queryClient.fetchQuery( [ 'legacydynamictreeitems', - connectionId.toLowerCase(), + getConnectionQueryKey(connectionId), connectorId.toLowerCase(), operationId?.toLowerCase(), getParametersKey(parameters).toLowerCase(), @@ -245,7 +246,7 @@ export const getDynamicTreeItems = async ( const values = await queryClient.fetchQuery( [ 'dynamictreeitems', - connectionId.toLowerCase(), + getConnectionQueryKey(connectionId), connectorId.toLowerCase(), operationId?.toLowerCase(), getParametersKey(parameters).toLowerCase(), diff --git a/libs/designer-v2/src/lib/core/state/__test__/connectionSlice.spec.ts b/libs/designer-v2/src/lib/core/state/__test__/connectionSlice.spec.ts index 948d64ebf5b..3182396b448 100644 --- a/libs/designer-v2/src/lib/core/state/__test__/connectionSlice.spec.ts +++ b/libs/designer-v2/src/lib/core/state/__test__/connectionSlice.spec.ts @@ -1,8 +1,68 @@ import { describe, expect, it } from 'vitest'; import { getMockedUndoRedoPartialRootState } from '../../../__test__/mock-root-state'; -import reducer, { initialConnectionsState, initializeConnectionsMappings } from '../connection/connectionSlice'; +import reducer, { + changeConnectionMapping, + initialConnectionsState, + initializeConnectionReferences, + initializeConnectionsMappings, +} from '../connection/connectionSlice'; import { setStateAfterUndoRedo } from '../global'; describe('connection slice reducers', () => { + it('uses the exact new ServiceProvider connection ID tail rather than a connector alias', () => { + const connectorId = '/serviceProviders/Sftp'; + const connectionId = `${connectorId}/connections/SftpTeamA`; + const state = reducer(initialConnectionsState, changeConnectionMapping({ nodeId: 'Upload', connectorId, connectionId })); + + expect(state.connectionsMapping.Upload).toBe('SftpTeamA'); + expect(Object.keys(state.connectionReferences)).toEqual(['SftpTeamA']); + expect(state.connectionReferences.SftpTeamA).toMatchObject({ + api: { id: connectorId }, + connection: { id: connectionId }, + connectionName: 'SftpTeamA', + }); + + const nextState = reducer( + state, + changeConnectionMapping({ + nodeId: 'OtherUpload', + connectorId, + connectionId: `${connectorId}/connections/sftpteama`, + }) + ); + expect(nextState.connectionsMapping).toEqual({ Upload: 'SftpTeamA', OtherUpload: 'sftpteama' }); + expect(Object.keys(nextState.connectionReferences)).toEqual(['SftpTeamA', 'sftpteama']); + expect(nextState.connectionReferences.SftpTeamA).toBe(state.connectionReferences.SftpTeamA); + }); + + it('reuses an existing ServiceProvider reference key without renaming it', () => { + const connectorId = '/serviceProviders/Sftp'; + const connectionId = `${connectorId}/connections/SftpTeamA`; + const initial = reducer( + initialConnectionsState, + initializeConnectionReferences({ + ExistingAlias: { api: { id: connectorId }, connection: { id: connectionId } }, + }) + ); + const state = reducer(initial, changeConnectionMapping({ nodeId: 'Upload', connectorId, connectionId })); + + expect(state.connectionsMapping.Upload).toBe('ExistingAlias'); + expect(state.connectionReferences).toBe(initial.connectionReferences); + }); + + it('keeps connector-based aliases for new non-ServiceProvider connections', () => { + const state = reducer( + initialConnectionsState, + changeConnectionMapping({ + nodeId: 'Upload', + connectorId: '/managedApis/Sftp', + connectionId: '/connections/SftpTeamA', + }) + ); + + expect(state.connectionsMapping.Upload).toBe('Sftp'); + expect(Object.keys(state.connectionReferences)).toEqual(['Sftp']); + }); + it('should set node mappings when initialised', async () => { const connectionsMapping: Record = { first: 'first', diff --git a/libs/designer-v2/src/lib/core/state/connection/connectionSelector.ts b/libs/designer-v2/src/lib/core/state/connection/connectionSelector.ts index 532b20498dd..4ca1656f9c4 100644 --- a/libs/designer-v2/src/lib/core/state/connection/connectionSelector.ts +++ b/libs/designer-v2/src/lib/core/state/connection/connectionSelector.ts @@ -1,4 +1,9 @@ -import type { ConnectionMapping, ConnectionReference, ConnectionReferences } from '../../../common/models/workflow'; +import { + isExpressionConnectionMapping, + type ConnectionMapping, + type ConnectionReference, + type ConnectionReferences, +} from '../../../common/models/workflow'; import { useConnectionResource, useConnectionsForConnector } from '../../queries/connections'; import type { RootState } from '../../store'; import { getConnectionReference, isConnectionMultiAuthManagedIdentityType } from '../../utils/connectors/connections'; @@ -18,7 +23,7 @@ import { useMemo } from 'react'; import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import type { ConnectionsStoreState } from './connectionSlice'; +import type { ConnectionsStoreState, CopiedConnectionData } from './connectionSlice'; import Constants from '../../../common/constants'; export const useConnector = (connectorId?: string, enabled = true, useCachedData = false): UseQueryResult => @@ -98,8 +103,10 @@ export const useNodeConnectionId = (nodeId: string): string => { const connectionsMapping = useConnectionMapping(); const connectionReferences = useConnectionRefs(); return useMemo(() => { - const mapping = getRecordEntry(connectionsMapping, nodeId) ?? ''; - const reference = getRecordEntry(connectionReferences, mapping); + const reference = getConnectionReference({ connectionsMapping, connectionReferences }, nodeId); + if (reference?.connection?.id.startsWith('__MOCK')) { + return ''; + } return reference?.connection?.id ?? ''; }, [connectionsMapping, connectionReferences, nodeId]); }; @@ -110,6 +117,9 @@ export const useConnectionMapping = (): ConnectionMapping => { }); }; +export const useNodeConnectionMapping = (nodeId: string): ConnectionMapping[string] | undefined => + useSelector((state: RootState) => getRecordEntry(state.connections.connectionsMapping, nodeId)); + export const useConnectionRefs = (): ConnectionReferences => { return useSelector((state: RootState) => { return state.connections.connectionReferences; @@ -152,9 +162,17 @@ export const useShowIdentitySelectorQuery = (nodeId: string) => { export const getConnectionReferenceForNodeId = ( connectionState: ConnectionsStoreState, nodeId: string -): { connectionReference: ConnectionReference; referenceKey: string } | undefined => { +): CopiedConnectionData | undefined => { const { connectionReferences, connectionsMapping } = connectionState; const referenceKey = connectionsMapping[nodeId]; + if (isExpressionConnectionMapping(referenceKey)) { + const designTimeReferenceKey = referenceKey.designTimeReferenceKey; + return { + mapping: referenceKey, + referenceKey: designTimeReferenceKey, + connectionReference: designTimeReferenceKey ? connectionReferences[designTimeReferenceKey] : undefined, + }; + } return referenceKey ? { connectionReference: connectionReferences[referenceKey], referenceKey } : undefined; }; diff --git a/libs/designer-v2/src/lib/core/state/connection/connectionSlice.ts b/libs/designer-v2/src/lib/core/state/connection/connectionSlice.ts index 5fd9a4a7e8b..3edcb6f67b3 100644 --- a/libs/designer-v2/src/lib/core/state/connection/connectionSlice.ts +++ b/libs/designer-v2/src/lib/core/state/connection/connectionSlice.ts @@ -1,5 +1,13 @@ import { getExistingReferenceKey } from '../../utils/connectors/connections'; -import type { ConnectionMapping, ConnectionReference, ConnectionReferences, NodeId, ReferenceKey } from '../../../common/models/workflow'; +import { + isExpressionConnectionMapping, + type ConnectionMapping, + type ConnectionReference, + type ConnectionReferences, + type NodeId, + type ExpressionConnectionMapping, +} from '../../../common/models/workflow'; +import { remapConnectionExpression } from '../../utils/connectors/connectionExpression'; import type { UpdateConnectionPayload } from '../../actions/bjsworkflow/connections'; import { resetWorkflowState, setStateAfterUndoRedo } from '../global'; import { LogEntryLevel, LoggerService, getResourceNameFromId, getUniqueName } from '@microsoft/logic-apps-shared'; @@ -24,7 +32,11 @@ export const initialConnectionsState: ConnectionsStoreState = { }, }; -type ConnectionReferenceMap = Record; +export interface CopiedConnectionData { + connectionReference?: ConnectionReference; + referenceKey?: string; + mapping?: ExpressionConnectionMapping; +} export const connectionSlice = createSlice({ name: 'connections', @@ -36,6 +48,17 @@ export const connectionSlice = createSlice({ initializeConnectionsMappings: (state, action: PayloadAction) => { state.connectionsMapping = action.payload; }, + setNodeConnectionMapping: (state, action: PayloadAction<{ nodeId: string; mapping: ConnectionMapping[string] }>) => { + state.connectionsMapping[action.payload.nodeId] = action.payload.mapping; + }, + renameConnectionExpressionParameter: (state, action: PayloadAction<{ oldName: string; newName: string }>) => { + const { oldName, newName } = action.payload; + for (const mapping of Object.values(state.connectionsMapping)) { + if (isExpressionConnectionMapping(mapping)) { + mapping.expression = remapConnectionExpression(mapping.expression, {}, { [oldName]: newName }); + } + } + }, changeConnectionMapping: (state, action: PayloadAction) => { const { key, reference } = getReferenceForConnection(state.connectionReferences, action.payload); @@ -76,23 +99,36 @@ export const connectionSlice = createSlice({ state.connectionsMapping[nodeId] = null; } }, - initCopiedConnectionMap: (state, action: PayloadAction<{ connectionReferences: ConnectionReferenceMap }>) => { + initCopiedConnectionMap: (state, action: PayloadAction<{ connectionReferences: ConnectionMapping }>) => { const { connectionReferences } = action.payload; Object.entries(connectionReferences).forEach(([nodeId, referenceKey]) => { - if (referenceKey && state.connectionReferences[referenceKey]) { + if (isExpressionConnectionMapping(referenceKey)) { + const { designTimeReferenceKey, ...mapping } = referenceKey; + state.connectionsMapping[nodeId] = { + ...mapping, + ...(designTimeReferenceKey && state.connectionReferences[designTimeReferenceKey] ? { designTimeReferenceKey } : {}), + }; + } else if (referenceKey && state.connectionReferences[referenceKey]) { state.connectionsMapping[nodeId] = referenceKey; } }); }, - initScopeCopiedConnections: ( - state, - action: PayloadAction> - ) => { + initScopeCopiedConnections: (state, action: PayloadAction>) => { const copiedConnections = action.payload; - Object.entries(copiedConnections).forEach(([nodeId, { connectionReference, referenceKey }]) => { + Object.entries(copiedConnections).forEach(([nodeId, { connectionReference, referenceKey, mapping }]) => { + if (mapping) { + if (referenceKey && connectionReference && !state.connectionReferences[referenceKey]) { + state.connectionReferences[referenceKey] = connectionReference; + } + state.connectionsMapping[nodeId] = mapping; + return; + } + if (!referenceKey) { + return; + } if (referenceKey && state.connectionReferences[referenceKey]) { state.connectionsMapping[nodeId] = referenceKey; - } else { + } else if (connectionReference) { state.connectionReferences[referenceKey] = connectionReference; state.connectionsMapping[nodeId] = referenceKey; } @@ -134,7 +170,10 @@ const getReferenceForConnection = ( return { key: existingReferenceKey }; } - const { name: newReferenceKey } = getUniqueName(Object.keys(references), connectorId.split('/').at(-1) as string); + // Service provider expressions address the connections.json key directly. + const newReferenceKey = /(^|\/)serviceProviders\//i.test(connectorId) + ? getResourceNameFromId(connectionId) + : getUniqueName(Object.keys(references), connectorId.split('/').at(-1) as string).name; return { key: newReferenceKey, reference: { @@ -152,6 +191,8 @@ const getReferenceForConnection = ( export const { initializeConnectionReferences, initializeConnectionsMappings, + setNodeConnectionMapping, + renameConnectionExpressionParameter, changeConnectionMapping, initEmptyConnectionMap, initCopiedConnectionMap, diff --git a/libs/designer-v2/src/lib/core/state/mcp/__test__/fixtures.tsx b/libs/designer-v2/src/lib/core/state/mcp/__test__/fixtures.tsx new file mode 100644 index 00000000000..219321fa441 --- /dev/null +++ b/libs/designer-v2/src/lib/core/state/mcp/__test__/fixtures.tsx @@ -0,0 +1,71 @@ +import { configureStore, type AnyAction } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; +import { IntlProvider } from 'react-intl'; +import type { PropsWithChildren } from 'react'; +import type { ParameterInfo } from '@microsoft/logic-apps-shared'; +import { initialConnectionsState } from '../../connection/connectionSlice'; +import { initialState as initialOperationsState } from '../../operation/operationMetadataSlice'; +import type { RootState } from '../store'; +import { McpPanelView } from '../panel/mcpPanelSlice'; +import { createLiteralValueSegment } from '../../../utils/parameters/segment'; + +export const connectorId = '/serviceProviders/sql'; +export const operationId = 'Query'; +export const reference = { api: { id: connectorId }, connection: { id: '/connections/Sql' } }; +export const expressionMapping = { + kind: 'expression' as const, + expression: "@parameters('connectionName')", + designTimeReferenceKey: 'Sql', +}; + +export const createMcpState = (): Pick => ({ + connection: { + ...initialConnectionsState, + connectionsMapping: { [operationId]: 'Sql' }, + connectionReferences: { Sql: reference }, + loading: { ...initialConnectionsState.loading }, + }, + operations: { + ...initialOperationsState, + operationInfo: { [operationId]: { connectorId, operationId, type: 'ServiceProvider' } }, + inputParameters: { [operationId]: { parameterGroups: { default: { id: 'default', description: '', parameters: [] } } } }, + dependencies: { [operationId]: { inputs: {}, outputs: {} } }, + }, + mcpPanel: { isOpen: true, currentPanelView: McpPanelView.SelectConnector }, + mcpSelection: { selectedConnectorId: connectorId, selectedOperations: [operationId], errors: {} }, +}); + +// Keep Redux subscriptions real. Effects outside these component/selector units are +// recorded at the dispatch boundary rather than contacting connector services. +export const createMcpHarness = (state = createMcpState()) => { + const actions: AnyAction[] = []; + const store = configureStore({ + reducer: (current = state) => current, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(() => (next) => (action) => { + actions.push(action); + return next(action); + }), + }); + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ); + return { store, actions, wrapper }; +}; + +export const createParameter = (overrides: Partial = {}): ParameterInfo => + ({ + id: 'body', + parameterKey: 'inputs.$.body', + label: 'Payload', + type: 'string', + required: false, + value: [createLiteralValueSegment('original')], + editor: 'string', + editorOptions: {}, + editorViewModel: {}, + info: {}, + ...overrides, + }) as ParameterInfo; diff --git a/libs/designer-v2/src/lib/core/state/mcp/__test__/selector.spec.tsx b/libs/designer-v2/src/lib/core/state/mcp/__test__/selector.spec.tsx new file mode 100644 index 00000000000..bf89203ef07 --- /dev/null +++ b/libs/designer-v2/src/lib/core/state/mcp/__test__/selector.spec.tsx @@ -0,0 +1,78 @@ +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { useAllReferenceKeys, useAreMappingsInitialized, useConnectionReference, useOperationNodeIds } from '../selector'; +import { connectorId, createMcpHarness, createMcpState, expressionMapping, reference } from './fixtures'; + +afterEach(cleanup); + +describe('MCP connection selectors', () => { + it('groups only concrete references, ignoring a leading runtime expression and unassigned nodes', () => { + const state = createMcpState(); + state.connection.connectionsMapping = { runtime: expressionMapping, unassigned: null, first: 'Sql', second: 'Sql', other: 'Blob' }; + state.connection.connectionReferences.Blob = { api: { id: '/serviceProviders/blob' }, connection: { id: '/connections/Blob' } }; + const { result } = renderHook( + () => ({ + reference: useConnectionReference(), + sqlNodes: useOperationNodeIds(connectorId.toUpperCase()), + blobNodes: useOperationNodeIds('/serviceProviders/blob'), + keys: useAllReferenceKeys(), + }), + createMcpHarness(state) + ); + expect(result.current).toEqual({ + reference, + sqlNodes: ['first', 'second'], + blobNodes: ['other'], + keys: ['Sql', 'Blob'], + }); + }); + + it.each(['expression', 'null', 'missing', 'dangling reference'] as const)( + 'does not treat %s mappings as usable concrete connections', + (kind) => { + const state = createMcpState(); + state.connection.connectionsMapping = + kind === 'missing' ? {} : { Query: kind === 'expression' ? expressionMapping : kind === 'null' ? null : 'Unknown' }; + const { result } = renderHook( + () => ({ reference: useConnectionReference(), nodes: useOperationNodeIds(connectorId) }), + createMcpHarness(state) + ); + expect(result.current).toEqual({ reference: undefined, nodes: [] }); + } + ); + + it('skips dangling references when looking up a connector and returns no nodes for an unknown connector', () => { + const state = createMcpState(); + state.connection.connectionsMapping = { orphan: 'Unknown', Query: 'Sql' }; + const { result } = renderHook( + () => ({ sql: useOperationNodeIds(connectorId), unknown: useOperationNodeIds('/serviceProviders/unknown') }), + createMcpHarness(state) + ); + expect(result.current).toEqual({ sql: ['Query'], unknown: [] }); + }); + + it('distinguishes initialized mappings from usable references, including null and expression mappings', () => { + const state = createMcpState(); + state.connection.connectionsMapping = { concrete: 'Sql', runtime: expressionMapping, unassigned: null }; + const { result } = renderHook( + () => ({ + initialized: useAreMappingsInitialized(['concrete', 'runtime', 'unassigned']), + missing: useAreMappingsInitialized(['concrete', 'absent']), + empty: useAreMappingsInitialized([]), + }), + createMcpHarness(state) + ); + expect(result.current).toEqual({ initialized: true, missing: false, empty: true }); + }); + + it('returns no keys or nodes before any connections are initialized', () => { + const state = createMcpState(); + state.connection.connectionReferences = {}; + state.connection.connectionsMapping = {}; + const { result } = renderHook( + () => ({ keys: useAllReferenceKeys(), nodes: useOperationNodeIds(connectorId) }), + createMcpHarness(state) + ); + expect(result.current).toEqual({ keys: [], nodes: [] }); + }); +}); diff --git a/libs/designer-v2/src/lib/core/state/mcp/selector.ts b/libs/designer-v2/src/lib/core/state/mcp/selector.ts index b4140da841d..97663b0b0c8 100644 --- a/libs/designer-v2/src/lib/core/state/mcp/selector.ts +++ b/libs/designer-v2/src/lib/core/state/mcp/selector.ts @@ -1,4 +1,5 @@ import { useSelector } from 'react-redux'; +import type { ConnectionMapping } from '../../../common/models/workflow'; import type { RootState } from './store'; import { equals, type ConnectionReference } from '@microsoft/logic-apps-shared'; @@ -15,7 +16,7 @@ export const useAllReferenceKeys = (): string[] => { export const useConnectionReference = (): ConnectionReference | undefined => { const state = useSelector((state: RootState) => state.connection); - const operationsGroupedByReferences = getOperationsGroupedByReferences(state.connectionsMapping as Record); + const operationsGroupedByReferences = getOperationsGroupedByReferences(state.connectionsMapping); // There should be only one reference per connector, and in first release it is only one connector so defaulting to the first one. const firstReferenceKey = Object.keys(operationsGroupedByReferences)[0] as string; @@ -24,7 +25,7 @@ export const useConnectionReference = (): ConnectionReference | undefined => { export const useOperationNodeIds = (connectorId: string): string[] => { const state = useSelector((state: RootState) => state.connection); - const operationsGroupedByReferences = getOperationsGroupedByReferences(state.connectionsMapping as Record); + const operationsGroupedByReferences = getOperationsGroupedByReferences(state.connectionsMapping); for (const [referenceKey, nodeIds] of Object.entries(operationsGroupedByReferences)) { const connectionReference = state.connectionReferences[referenceKey]; @@ -36,8 +37,11 @@ export const useOperationNodeIds = (connectorId: string): string[] => { return []; }; -const getOperationsGroupedByReferences = (mapping: Record): Record => { +const getOperationsGroupedByReferences = (mapping: ConnectionMapping): Record => { return Object.entries(mapping).reduce((result: Record, [nodeId, referenceKey]) => { + if (typeof referenceKey !== 'string') { + return result; + } if (!result[referenceKey]) { result[referenceKey] = []; } diff --git a/libs/designer-v2/src/lib/core/state/operation/operationMetadataSlice.ts b/libs/designer-v2/src/lib/core/state/operation/operationMetadataSlice.ts index 982aa9324b4..7a488c3ac37 100644 --- a/libs/designer-v2/src/lib/core/state/operation/operationMetadataSlice.ts +++ b/libs/designer-v2/src/lib/core/state/operation/operationMetadataSlice.ts @@ -60,6 +60,7 @@ export const DynamicLoadStatus = { export type DynamicLoadStatus = (typeof DynamicLoadStatus)[keyof typeof DynamicLoadStatus]; export interface NodeInputs { + preservedConnectionInputs?: Record; dynamicLoadStatus?: DynamicLoadStatus; parameterGroups: Record; /** @@ -352,7 +353,14 @@ export const operationMetadataSlice = createSlice({ // but keep entries for other dynamic refs that haven't loaded yet. if (inputParameters?.stashedDynamicParameterValues?.length) { const loadedKeys = new Set(inputs.map((p) => p.parameterKey)); - const remaining = inputParameters.stashedDynamicParameterValues.filter((p) => !loadedKeys.has(p.parameterKey)); + const expandedReferences = new Set( + inputs.filter((p) => p.parameterKey !== p.info.dynamicParameterReference).map((p) => p.info.dynamicParameterReference) + ); + const remaining = inputParameters.stashedDynamicParameterValues.filter( + (p) => + !loadedKeys.has(p.parameterKey) && + !(p.parameterKey === p.info.dynamicParameterReference && expandedReferences.has(p.parameterKey)) + ); if (remaining.length > 0) { inputParameters.stashedDynamicParameterValues = remaining; } else { @@ -597,12 +605,16 @@ export const operationMetadataSlice = createSlice({ action: PayloadAction<{ nodeId: string; parameterGroups: Record; + preservedConnectionInputs?: Record; }> ) => { - const { nodeId, parameterGroups } = action.payload; + const { nodeId, parameterGroups, preservedConnectionInputs } = action.payload; const nodeInputs = getRecordEntry(state.inputParameters, nodeId); if (nodeInputs) { nodeInputs.parameterGroups = parameterGroups; + if (preservedConnectionInputs !== undefined) { + nodeInputs.preservedConnectionInputs = preservedConnectionInputs; + } } }, updateParameterConditionalVisibility: ( diff --git a/libs/designer-v2/src/lib/core/state/undoRedo/undoRedoTypes.ts b/libs/designer-v2/src/lib/core/state/undoRedo/undoRedoTypes.ts index 9eb33f02651..fc361f12298 100644 --- a/libs/designer-v2/src/lib/core/state/undoRedo/undoRedoTypes.ts +++ b/libs/designer-v2/src/lib/core/state/undoRedo/undoRedoTypes.ts @@ -9,7 +9,6 @@ import { addSwitchCase, moveNode, pasteNode, - pasteScopeNode, removeRunAfter, replaceId, updateRunAfter, @@ -60,7 +59,6 @@ export const undoableWorkflowActionTypes = [ addAgentTool, addForeachToNode.pending, pasteNode, - pasteScopeNode, updateRunAfter, removeRunAfter, addRunAfter, @@ -82,4 +80,8 @@ export const undoablePanelActionTypes = [ */ ].map((action) => action.type); -export const undoableActionTypes = undoableWorkflowActionTypes.concat(undoablePanelActionTypes); +export const undoableActionTypes = undoableWorkflowActionTypes.concat(undoablePanelActionTypes, [ + 'updateNodeConnectionExpression/pending', + 'updateNodeConnection/pending', + 'pasteScopeOperation/pending', +]); diff --git a/libs/designer-v2/src/lib/core/utils/__test__/middleware.spec.ts b/libs/designer-v2/src/lib/core/utils/__test__/middleware.spec.ts index 4525f3ae905..81e87cad587 100644 --- a/libs/designer-v2/src/lib/core/utils/__test__/middleware.spec.ts +++ b/libs/designer-v2/src/lib/core/utils/__test__/middleware.spec.ts @@ -59,7 +59,13 @@ describe('middleware utils', () => { vi.spyOn(undoRedoUtils, 'getEditedPanelTab').mockReturnValue(undefined); vi.spyOn(undoRedoUtils, 'getEditedPanelNode').mockReturnValue(undefined); - const action = { type: undoableActionType }; + const action = { type: undoableActionType, meta: { arg: { nodeId: 'node1' } } }; + if (undoableActionType === 'updateNodeConnection/pending') { + store.getState.mockReturnValue({ + ...createMockState(), + connections: { connectionsMapping: { node1: { kind: 'expression', expression: '@triggerBody()' } } }, + }); + } invoke(action); expect(next).toHaveBeenCalledWith(action); @@ -132,6 +138,54 @@ describe('middleware utils', () => { expect(store.dispatch).not.toHaveBeenCalled(); expect(compressSpy).not.toHaveBeenCalled(); }); + + it.each(['Sql', null, undefined])('does not save history for a concrete connection update when the prior mapping is %s', (mapping) => { + const compressSpy = vi.spyOn(undoRedoUtils, 'getCompressedSlicesFromRootState'); + store.getState.mockReturnValue({ + ...createMockState(), + connections: { + connectionsMapping: { + node1: mapping, + otherNode: { kind: 'expression', expression: '@triggerBody()' }, + }, + }, + }); + const action = { type: 'updateNodeConnection/pending', meta: { arg: { nodeId: 'node1' } } }; + next.mockReturnValue('forwarded'); + + expect(invoke(action)).toBe('forwarded'); + expect(next).toHaveBeenCalledWith(action); + expect(compressSpy).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('saves the prior expression mapping when replacing it with a concrete connection', () => { + const beforeState = { + ...createMockState(), + connections: { connectionsMapping: { node1: { kind: 'expression', expression: '@triggerBody()' } } }, + }; + const compressedSlices = { connections: new Uint8Array([1, 2, 3]) }; + const compressSpy = vi.spyOn(undoRedoUtils, 'getCompressedSlicesFromRootState').mockReturnValue(compressedSlices); + vi.spyOn(undoRedoUtils, 'shouldSkipSavingStateToHistory').mockReturnValue(false); + vi.spyOn(undoRedoUtils, 'getEditedPanelTab').mockReturnValue(undefined); + vi.spyOn(undoRedoUtils, 'getEditedPanelNode').mockReturnValue(undefined); + store.getState.mockReturnValue(beforeState); + next.mockImplementation(() => { + store.getState.mockReturnValue({ ...beforeState, connections: { connectionsMapping: { node1: 'Sql' } } }); + }); + const action = { type: 'updateNodeConnection/pending', meta: { arg: { nodeId: 'node1' } } }; + + invoke(action); + + expect(next).toHaveBeenCalledExactlyOnceWith(action); + expect(compressSpy).toHaveBeenCalledExactlyOnceWith(beforeState); + expect(store.dispatch).toHaveBeenCalledExactlyOnceWith( + saveStateToHistory({ + stateHistoryItem: { compressedSlices, editedPanelTab: undefined, editedPanelNode: undefined }, + limit: CONSTANTS.DEFAULT_MAX_STATE_HISTORY_SIZE, + }) + ); + }); }); describe('monitoringDirtyGuardMiddleware', () => { diff --git a/libs/designer-v2/src/lib/core/utils/connectors/connectionExpression.ts b/libs/designer-v2/src/lib/core/utils/connectors/connectionExpression.ts new file mode 100644 index 00000000000..010c79de65f --- /dev/null +++ b/libs/designer-v2/src/lib/core/utils/connectors/connectionExpression.ts @@ -0,0 +1,97 @@ +import type { ConnectionMapping } from '../../../common/models/workflow'; +import { + ExpressionBuilder, + ExpressionParser, + isFunction, + isStringInterpolation, + isStringLiteral, + isTemplateExpression, + type Expression, +} from '@microsoft/logic-apps-shared'; + +export function getServiceProviderConnectionMapping(connectionName: string): ConnectionMapping[string] { + if (!isTemplateExpression(connectionName)) { + return connectionName; + } + try { + const expression = ExpressionParser.parseTemplateExpression(connectionName); + if (isStringLiteral(expression) && connectionName.charAt(0) === '@' && connectionName.charAt(1) === '@') { + return expression.value; + } + if (isStringInterpolation(expression) && expression.segments.every(isStringLiteral) && !/(^|[^@])@\{/.test(connectionName)) { + return expression.segments.map((segment) => (isStringLiteral(segment) ? segment.value : '')).join(''); + } + } catch { + // Malformed expressions must survive import, not become connection resource IDs. + } + return { kind: 'expression', expression: connectionName }; +} + +export function isConnectionExpressionValid(expression: string): boolean { + try { + ExpressionParser.parseTemplateExpression(expression); + return typeof getServiceProviderConnectionMapping(expression) === 'object'; + } catch { + return false; + } +} + +export function remapConnectionExpression( + value: string, + actionNames: Record = {}, + parameterNames: Record = {} +): string { + if (!isConnectionExpressionValid(value)) { + return value; + } + const expression = ExpressionParser.parseTemplateExpression(value); + let changed = false; + const visit = (node: Expression): void => { + if (isStringInterpolation(node)) { + node.segments.forEach(visit); + } else if (isFunction(node)) { + const name = node.name.toLowerCase(); + const replacements = + name === 'parameters' + ? parameterNames + : ['outputs', 'body', 'actions', 'actionoutputs', 'actionbody', 'items', 'iterationindexes', 'result'].includes(name) + ? actionNames + : {}; + const argument = node.arguments[0]; + if (argument && isStringLiteral(argument) && Object.hasOwn(replacements, argument.value)) { + argument.value = replacements[argument.value]; + changed = true; + } + node.arguments.forEach(visit); + node.dereferences.forEach((dereference) => visit(dereference.expression)); + } + }; + visit(expression); + if (!changed) { + return value; + } + return new ExpressionBuilder().buildTemplateExpression(expression); +} + +export function serializeServiceProviderConnectionKey(referenceKey: string): string { + return referenceKey.charAt(0) === '@' ? `@${referenceKey}` : referenceKey.replace(/@\{/g, '@@{'); +} + +export function remapConnectionExpressionValue( + value: any, + actionNames: Record, + parameterNames: Record = {} +): any { + if (typeof value === 'string') { + return remapConnectionExpression(value, actionNames, parameterNames); + } + if (Array.isArray(value)) { + return value.map((child) => remapConnectionExpressionValue(child, actionNames, parameterNames)); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, remapConnectionExpressionValue(child, actionNames, parameterNames)]) + ); + } + return value; +} diff --git a/libs/designer-v2/src/lib/core/utils/connectors/connections.ts b/libs/designer-v2/src/lib/core/utils/connectors/connections.ts index 4836fe0a2a9..c7f89f1412f 100644 --- a/libs/designer-v2/src/lib/core/utils/connectors/connections.ts +++ b/libs/designer-v2/src/lib/core/utils/connectors/connections.ts @@ -1,5 +1,5 @@ import constants from '../../../common/constants'; -import type { ConnectionReference, ConnectionReferences } from '../../../common/models/workflow'; +import { isExpressionConnectionMapping, type ConnectionReference, type ConnectionReferences } from '../../../common/models/workflow'; import { getConnection } from '../../queries/connections'; import { getOperationManifest } from '../../queries/operation'; import type { ConnectionsStoreState } from '../../state/connection/connectionSlice'; @@ -19,6 +19,7 @@ import { getRecordEntry, getPropertyValue, deepCompareObjects, + isTemplateExpression, } from '@microsoft/logic-apps-shared'; import type { AssistedConnectionProps } from '@microsoft/designer-ui'; import type { @@ -35,13 +36,25 @@ export function getConnectionId(state: ConnectionsStoreState, nodeId: string): s return getConnectionReference(state, nodeId)?.connection?.id ?? ''; } -export function getConnectionReference(state: ConnectionsStoreState, nodeId: string): ConnectionReference { +export function getConnectionReference( + state: Pick, + nodeId: string +): ConnectionReference | undefined { const { connectionsMapping, connectionReferences } = state; const mappedConnectionReference = getRecordEntry(connectionsMapping, nodeId); + if (isExpressionConnectionMapping(mappedConnectionReference)) { + const referenceKey = mappedConnectionReference.designTimeReferenceKey; + const reference = referenceKey && Object.hasOwn(connectionReferences, referenceKey) ? connectionReferences[referenceKey] : undefined; + return reference?.connection.id && !isTemplateExpression(reference.connection.id) && !reference.connection.id.startsWith('__MOCK') + ? reference + : undefined; + } if (!mappedConnectionReference) { return mockEmptyConnectionReference; } - const connectionReference = getRecordEntry(connectionReferences, mappedConnectionReference); + const connectionReference = Object.hasOwn(connectionReferences, mappedConnectionReference) + ? connectionReferences[mappedConnectionReference] + : undefined; if (!connectionReference) { return mockInvalidConnectionReference; } @@ -78,7 +91,12 @@ export async function isConnectionReferenceValid( return !!reference && !reference.connection.id.startsWith('__MOCK'); } } - if (!reference) { + if ( + !reference || + !reference.connection.id || + reference.connection.id.startsWith('__MOCK') || + isTemplateExpression(reference.connection.id) + ) { return false; } @@ -99,7 +117,9 @@ export function getExistingReferenceKey( const reference = allReferences[referenceKey]; return ( equals(reference.api.id, connectorId) && - equals(reference.connection.id, connectionId) && + (/(^|\/)serviceProviders\//i.test(connectorId) + ? reference.connection.id === connectionId + : equals(reference.connection.id, connectionId)) && equals(reference.connectionRuntimeUrl ?? '', connectionRuntimeUrl ?? '') && deepCompareObjects(reference.connectionProperties, connectionProperties) ); diff --git a/libs/designer-v2/src/lib/core/utils/middleware.ts b/libs/designer-v2/src/lib/core/utils/middleware.ts index 05d27d34434..f336cf20240 100644 --- a/libs/designer-v2/src/lib/core/utils/middleware.ts +++ b/libs/designer-v2/src/lib/core/utils/middleware.ts @@ -1,6 +1,8 @@ import { default as CONSTANTS } from '../../common/constants'; +import { isExpressionConnectionMapping } from '../../common/models/workflow'; import { saveStateToHistory } from '../state/undoRedo/undoRedoSlice'; import { undoableActionTypes } from '../state/undoRedo/undoRedoTypes'; +import { renameConnectionExpressionParameter } from '../state/connection/connectionSlice'; import { setIsWorkflowDirty } from '../state/workflow/workflowSlice'; import { setIsWorkflowParametersDirty } from '../state/workflowparameters/workflowparametersSlice'; import type { RootState } from '../store'; @@ -12,6 +14,19 @@ export const storeStateHistoryMiddleware: Middleware = ({ dispatch, getState }) => (next) => (action: any) => { + if ( + action.type === 'updateNodeConnection/pending' && + !isExpressionConnectionMapping((getState() as RootState).connections.connectionsMapping[action.meta.arg.nodeId]) + ) { + return next(action); + } + if (action.type === 'workflowParameters/updateParameter') { + const oldName = (getState() as RootState).workflowParameters.definitions[action.payload.id]?.name; + const newName = action.payload.newDefinition.name; + if (oldName && newName && oldName !== newName) { + dispatch(renameConnectionExpressionParameter({ oldName, newName })); + } + } if (!undoableActionTypes.includes(action.type)) { return next(action); } diff --git a/libs/designer-v2/src/lib/core/utils/parameters/dynamicdata.ts b/libs/designer-v2/src/lib/core/utils/parameters/dynamicdata.ts index 3f6fa518e53..3be94408361 100644 --- a/libs/designer-v2/src/lib/core/utils/parameters/dynamicdata.ts +++ b/libs/designer-v2/src/lib/core/utils/parameters/dynamicdata.ts @@ -101,6 +101,9 @@ export async function getDynamicValues( idReplacements: Record, workflowParameters: Record ): Promise { + if (!canInvokeDynamicConnection(operationInfo, connectionReference)) { + return []; + } const { definition } = dependencyInfo; const shouldEncodeBasedOnMetadata = shouldEncodeParameterValueForOperationBasedOnMetadata(operationInfo); if (isDynamicListExtension(definition)) { @@ -166,6 +169,9 @@ export async function getDynamicSchema( idReplacements: Record = {}, workflowParameters: Record ): Promise { + if (!canInvokeDynamicConnection(operationInfo, connectionReference)) { + return null; + } const { parameter, definition } = dependencyInfo; const emptySchema = { title: parameter?.schema?.title, @@ -372,6 +378,9 @@ export async function getFolderItems( idReplacements: Record, workflowParameters: Record ): Promise { + if (!canInvokeDynamicConnection(operationInfo, connectionReference)) { + return []; + } const { definition, filePickerInfo } = dependencyInfo; const shouldEncodeBasedOnMetadata = shouldEncodeParameterValueForOperationBasedOnMetadata(operationInfo); @@ -614,6 +623,18 @@ async function getManagedIdentityRequestProperties( return managedIdentityRequestProperties; } +export function canInvokeDynamicConnection(operationInfo: OperationInfo, reference: ConnectionReference | undefined): boolean { + if (!/(^|\/)serviceProviders\//i.test(operationInfo.connectorId)) { + return true; + } + return ( + !!reference?.connection.id && + equals(reference.api.id, operationInfo.connectorId) && + !reference.connection.id.startsWith('__MOCK') && + !isTemplateExpression(reference.connection.id) + ); +} + export function getManifestBasedInputParameters( dynamicInputs: InputParameter[], dynamicParameter: InputParameter, @@ -837,7 +858,7 @@ function getSwaggerBasedInputParameters( } // We should remove any reference to dynamic schema if parameter containing dynamic schema is used directly as an input. -function getDynamicInputParameterFromDynamicParameter(dynamicParameter: InputParameter): InputParameter { +export function getDynamicInputParameterFromDynamicParameter(dynamicParameter: InputParameter): InputParameter { const result = { ...dynamicParameter, isDynamic: true, diff --git a/libs/designer-v2/src/lib/core/utils/parameters/helper.ts b/libs/designer-v2/src/lib/core/utils/parameters/helper.ts index 3f8e54b3594..06a6f4b9cb7 100644 --- a/libs/designer-v2/src/lib/core/utils/parameters/helper.ts +++ b/libs/designer-v2/src/lib/core/utils/parameters/helper.ts @@ -42,7 +42,7 @@ import { extractPathFromUri, getOperationIdFromDefinition } from '../swagger/ope import { convertWorkflowParameterTypeToSwaggerType } from '../tokens'; import { validateJSONParameter, validateStaticParameterInfo } from '../validation'; import { addCastToExpression, addFoldingCastToExpression } from './casting'; -import { getDynamicInputsFromSchema, getDynamicSchema, getDynamicValues, getFolderItems } from './dynamicdata'; +import { canInvokeDynamicConnection, getDynamicInputsFromSchema, getDynamicSchema, getDynamicValues, getFolderItems } from './dynamicdata'; import { getRecurrenceParameters } from './recurrence'; import { createLiteralValueSegment, @@ -219,7 +219,7 @@ export interface UpdateParameterAndDependenciesPayload { properties: Partial; isTrigger: boolean; operationInfo: NodeOperation; - connectionReference: ConnectionReference; + connectionReference: ConnectionReference | undefined; nodeInputs: NodeInputs; dependencies: NodeDependencies; updateTokenMetadata?: boolean; @@ -1875,7 +1875,7 @@ export const updateParameterAndDependencies = createAsyncThunk( LoggerService().log({ level: LogEntryLevel.Verbose, area: 'UpdateParameterAndDependencies', - message: `Dependent parameter was not set. Connection name: ${connectionReference.connectionName} - Parameter key: ${key}`, + message: `Dependent parameter was not set. Connection name: ${connectionReference?.connectionName} - Parameter key: ${key}`, }); continue; } @@ -2027,6 +2027,9 @@ export const updateDynamicDataInNode = async ( loadDynamicOutputs = true, loadDefaultValues = true ): Promise => { + if (!canInvokeDynamicConnection(operationInfo, connectionReference)) { + return; + } await loadDynamicData( nodeId, isTrigger, @@ -2098,6 +2101,9 @@ async function loadDynamicData( loadDynamicOutputs = true, loadDefaultValues = true ): Promise { + if (!canInvokeDynamicConnection(operationInfo, connectionReference)) { + return; + } if (loadDynamicOutputs && Object.keys(dependencies?.outputs ?? {}).length) { const rootState = getState(); await loadDynamicOutputsInNode( @@ -2147,6 +2153,9 @@ export const loadDynamicContentForInputsInNode = async ( loadDynamicOutputs = true, loadDefaultValues = true ): Promise => { + if (!canInvokeDynamicConnection(operationInfo, connectionReference)) { + return; + } for (const [inputKey, info] of Object.entries(inputDependencies)) { if (info.dependencyType !== 'ApiSchema') { continue; diff --git a/libs/designer-v2/src/lib/ui/mcp/connectors/ListConnectors.tsx b/libs/designer-v2/src/lib/ui/mcp/connectors/ListConnectors.tsx index 8a1c053454e..9dbfba86581 100644 --- a/libs/designer-v2/src/lib/ui/mcp/connectors/ListConnectors.tsx +++ b/libs/designer-v2/src/lib/ui/mcp/connectors/ListConnectors.tsx @@ -119,7 +119,7 @@ export const ListConnectors = ({ addConnectors, addDisabled }: { addConnectors: seen.add(connectorId); const referenceKey = connectionsMapping[info.operationId]; - const reference = referenceKey ? connectionReferences[referenceKey] : null; + const reference = typeof referenceKey === 'string' ? connectionReferences[referenceKey] : null; const isConnected = !!reference; diff --git a/libs/designer-v2/src/lib/ui/mcp/connectors/__test__/ListConnectors.spec.tsx b/libs/designer-v2/src/lib/ui/mcp/connectors/__test__/ListConnectors.spec.tsx new file mode 100644 index 00000000000..80ec5d2664a --- /dev/null +++ b/libs/designer-v2/src/lib/ui/mcp/connectors/__test__/ListConnectors.spec.tsx @@ -0,0 +1,103 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ListConnectors, ConnectionDisplayName } from '../ListConnectors'; +import { useConnectionById } from '../../../../core/queries/connections'; +import * as mcpActions from '../../../../core/actions/bjsworkflow/mcp'; +import { McpPanelView, openConnectorPanelView } from '../../../../core/state/mcp/panel/mcpPanelSlice'; +import { selectConnectorId, selectOperations } from '../../../../core/state/mcp/mcpselectionslice'; +import { connectorId, createMcpHarness, createMcpState, expressionMapping } from '../../../../core/state/mcp/__test__/fixtures'; + +vi.mock('../../../../core/queries/connections', () => ({ useConnectionById: vi.fn() })); +vi.mock('../../../templates/connections/connector', () => ({ + ConnectorIconWithName: ({ connectorId, onNameClick }: { connectorId: string; onNameClick: () => void }) => ( + + ), +})); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useConnectionById).mockReturnValue({ result: { properties: { displayName: 'Production SQL' } } } as any); + vi.spyOn(mcpActions, 'deinitializeOperations').mockImplementation((payload) => ({ type: 'test/deinitialize', payload }) as any); +}); +afterEach(cleanup); + +describe('MCP connector list', () => { + it.each([false, true])('renders the empty-state add action, respecting disabled=%s', (addDisabled) => { + const state = createMcpState(); + state.operations.operationInfo = {}; + const addConnectors = vi.fn(); + render(, createMcpHarness(state)); + const add = screen.getByRole('button', { name: 'Add connector' }); + expect(add).toHaveProperty('disabled', addDisabled); + fireEvent.click(add); + expect(addConnectors).toHaveBeenCalledTimes(addDisabled ? 0 : 1); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it.each(['concrete', 'expression', 'null', 'missing', 'dangling reference'] as const)( + 'shows connection status for a %s mapping without promoting a design-time reference', + (kind) => { + const state = createMcpState(); + if (kind === 'missing') { + delete state.connection.connectionsMapping.Query; + } else { + state.connection.connectionsMapping.Query = + kind === 'expression' ? expressionMapping : kind === 'null' ? null : kind === 'concrete' ? 'Sql' : 'Unknown'; + } + const harness = createMcpHarness(state); + render(, harness); + expect(screen.getByRole('table', { name: 'List of connectors with their connections' })).toBeInTheDocument(); + expect(screen.getByText(kind === 'concrete' ? 'Connected' : 'Disconnected')).toBeInTheDocument(); + expect(screen.getByText(kind === 'concrete' ? 'Production SQL' : 'No Connection')).toBeInTheDocument(); + if (kind === 'concrete') { + expect(useConnectionById).toHaveBeenCalledWith('/connections/Sql', connectorId); + } else { + expect(useConnectionById).not.toHaveBeenCalled(); + } + expect(harness.actions).toEqual([]); + } + ); + + it.each(['name', 'edit button'])('edits all operations belonging to the connector via its %s', (entrypoint) => { + const state = createMcpState(); + state.operations.operationInfo.Second = { ...state.operations.operationInfo.Query, operationId: 'Second' }; + state.operations.operationInfo.Blob = { connectorId: '/serviceProviders/blob', operationId: 'Blob', type: 'ServiceProvider' }; + state.operations.operationInfo.Invalid = undefined as any; + const harness = createMcpHarness(state); + render(, harness); + expect(screen.getAllByRole('row')).toHaveLength(3); // header + two distinct connectors + const row = screen.getByRole('button', { name: connectorId }).closest('tr')!; + fireEvent.click( + entrypoint === 'name' + ? within(row).getByRole('button', { name: connectorId }) + : within(row).getByRole('button', { name: 'Edit connector' }) + ); + expect(harness.actions).toEqual([ + selectConnectorId(connectorId), + selectOperations(['Query', 'Second']), + openConnectorPanelView({ panelView: McpPanelView.CreateConnection }), + ]); + }); + + it('deletes only the selected connector operations, even when they use an expression', () => { + const state = createMcpState(); + state.connection.connectionsMapping.Query = expressionMapping; + state.operations.operationInfo.Second = { ...state.operations.operationInfo.Query, operationId: 'Second' }; + state.operations.operationInfo.Blob = { connectorId: '/serviceProviders/blob', operationId: 'Blob', type: 'ServiceProvider' }; + const harness = createMcpHarness(state); + render(, harness); + const row = screen.getByRole('button', { name: connectorId }).closest('tr')!; + fireEvent.click(within(row).getByRole('button', { name: 'Delete connector' })); + expect(harness.actions).toEqual([{ type: 'test/deinitialize', payload: { operationIds: ['Query', 'Second'] } }]); + }); + + it('falls back to the resource name while connection display metadata is unavailable', () => { + vi.mocked(useConnectionById).mockReturnValue({ result: undefined } as any); + render(, createMcpHarness()); + expect(screen.getByText('SqlFallback')).toBeInTheDocument(); + expect(useConnectionById).toHaveBeenCalledWith('/connections/SqlFallback', connectorId); + }); +}); diff --git a/libs/designer-v2/src/lib/ui/mcp/panel/connector/__test__/usePanelTabs.spec.tsx b/libs/designer-v2/src/lib/ui/mcp/panel/connector/__test__/usePanelTabs.spec.tsx new file mode 100644 index 00000000000..e2df458688a --- /dev/null +++ b/libs/designer-v2/src/lib/ui/mcp/panel/connector/__test__/usePanelTabs.spec.tsx @@ -0,0 +1,148 @@ +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as mcpActions from '../../../../../core/actions/bjsworkflow/mcp'; +import { LoggerService } from '@microsoft/logic-apps-shared'; +import { useMcpConnectorPanelTabs } from '../usePanelTabs'; +import { closePanel, McpPanelView } from '../../../../../core/state/mcp/panel/mcpPanelSlice'; +import { clearAllSelections } from '../../../../../core/state/mcp/mcpselectionslice'; +import { connectorId, createMcpHarness, createMcpState, expressionMapping } from '../../../../../core/state/mcp/__test__/fixtures'; + +vi.mock('@microsoft/logic-apps-shared', async (importOriginal) => ({ + ...(await importOriginal()), + LoggerService: vi.fn(() => ({ log: vi.fn() })), +})); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(mcpActions, 'initializeConnectionMappings').mockImplementation((payload) => ({ type: 'test/connections', payload }) as any); + vi.spyOn(mcpActions, 'initializeOperationsMetadata').mockImplementation((payload) => ({ type: 'test/metadata', payload }) as any); + vi.spyOn(mcpActions, 'deinitializeOperations').mockImplementation((payload) => ({ type: 'test/deinitialize', payload }) as any); +}); +afterEach(cleanup); + +const renderTabs = (state = createMcpState()) => { + const harness = createMcpHarness(state); + return { ...renderHook(useMcpConnectorPanelTabs, harness), ...harness }; +}; +const primary = (tab: ReturnType[number]) => tab.footerContent!.buttonContents[1]; + +describe('MCP connector panel tabs', () => { + it.each([ + [McpPanelView.SelectConnector, ['Choose connector', 'Select actions', 'Create connection']], + [McpPanelView.SelectOperation, ['Select actions', 'Create connection']], + [McpPanelView.UpdateOperation, ['Select actions']], + [McpPanelView.CreateConnection, ['Create connection']], + ] as const)('presents the tabs for %s', (view, titles) => { + const state = createMcpState(); + state.mcpPanel.currentPanelView = view; + const { result } = renderTabs(state); + expect(result.current.map((tab) => tab.title)).toEqual(titles); + if (view === McpPanelView.UpdateOperation) { + expect(primary(result.current[0]).text).toBe('Save'); + } + if (view === McpPanelView.CreateConnection) { + expect(result.current[0].footerContent!.buttonContents[0].text).toBe('Close'); + } + }); + + it.each(['concrete', 'expression', 'null', 'missing', 'dangling reference'] as const)( + 'allows connection submission only for a usable concrete reference (%s)', + (kind) => { + const state = createMcpState(); + state.mcpPanel.currentPanelView = McpPanelView.CreateConnection; + if (kind === 'missing') { + delete state.connection.connectionsMapping.Query; + } else { + state.connection.connectionsMapping.Query = + kind === 'expression' ? expressionMapping : kind === 'null' ? null : kind === 'concrete' ? 'Sql' : 'Unknown'; + } + const { result, actions } = renderTabs(state); + expect(primary(result.current[0]).disabled).toBe(kind !== 'concrete'); + expect(actions).toEqual([]); + } + ); + + it('does not let an unselected concrete operation validate selected expressions', () => { + const state = createMcpState(); + state.mcpSelection.selectedOperations = ['Runtime']; + state.connection.connectionsMapping.Runtime = expressionMapping; + const { result } = renderTabs(state); + expect(primary(result.current[2]).disabled).toBe(true); + }); + + it('keeps next/save disabled until connector and operations are selected', () => { + const state = createMcpState(); + state.mcpSelection.selectedConnectorId = undefined; + state.mcpSelection.selectedOperations = []; + const { result } = renderTabs(state); + expect(primary(result.current[0]).disabled).toBe(true); + expect(result.current[1].disabled).toBe(true); + expect(primary(result.current[1]).text).toBe('Next'); + expect(primary(result.current[1]).disabled).toBe(true); + expect(result.current[2].disabled).toBe(true); + expect(primary(result.current[2]).disabled).toBe(true); + }); + + it('shows loading and operation errors without allowing duplicate initialization', () => { + const state = createMcpState(); + state.connection.loading.initializeConnectionMappings = true; + state.mcpSelection.errors.operations = 'Could not load operations'; + const { result } = renderTabs(state); + expect(primary(result.current[1])).toMatchObject({ text: 'Next (1 selected)', loading: true, disabled: true }); + expect(result.current[1].tabStatusIcon).toBe('error'); + expect(result.current[2].disabled).toBe(true); + }); + + it.each([McpPanelView.SelectConnector, McpPanelView.SelectOperation])( + 'initializes connections from either navigation entrypoint in %s', + (view) => { + const state = createMcpState(); + state.mcpPanel.currentPanelView = view; + const { result, actions } = renderTabs(state); + const operations = result.current.find((tab) => tab.title === 'Select actions')!; + const connections = result.current.find((tab) => tab.title === 'Create connection')!; + act(() => { + primary(operations).onClick!(); + connections.onTabClick!(); + }); + const expected = { + type: 'test/connections', + payload: { connectorId, operations: ['Query'], area: view === McpPanelView.SelectConnector ? 'AddConnector' : 'EditConnector' }, + }; + expect(actions).toEqual([expected, expected]); + } + ); + + it.each([McpPanelView.SelectConnector, McpPanelView.CreateConnection])('saves existing selections and closes from %s', (view) => { + const state = createMcpState(); + state.mcpPanel.currentPanelView = view; + const { result, actions } = renderTabs(state); + act(() => primary(result.current.at(-1)!).onClick!()); + expect(actions).toEqual([closePanel(), clearAllSelections()]); + expect(LoggerService).toHaveBeenCalled(); + }); + + it('initializes only newly selected actions and deinitializes deselections when saving updated actions', () => { + const state = createMcpState(); + state.mcpPanel.currentPanelView = McpPanelView.UpdateOperation; + state.mcpSelection.selectedOperations = ['/operations/NewQuery']; + const { result, actions } = renderTabs(state); + act(() => primary(result.current[0]).onClick!()); + expect(actions).toEqual([ + { type: 'test/connections', payload: { connectorId, operations: ['/operations/NewQuery'], area: 'AddActions' } }, + { type: 'test/deinitialize', payload: { operationIds: ['Query'] } }, + { + type: 'test/metadata', + payload: { operations: [{ connectorId, operationId: 'NewQuery', type: 'apiconnection' }], area: 'AddActions' }, + }, + ]); + }); + + it('does not submit an invalid empty selection even if a stale footer callback is invoked', () => { + const state = createMcpState(); + state.mcpSelection.selectedOperations = []; + const { result, actions } = renderTabs(state); + act(() => primary(result.current.at(-1)!).onClick!()); + expect(actions).toEqual([]); + }); +}); diff --git a/libs/designer-v2/src/lib/ui/mcp/panel/connector/usePanelTabs.tsx b/libs/designer-v2/src/lib/ui/mcp/panel/connector/usePanelTabs.tsx index fa396582f77..07455719d32 100644 --- a/libs/designer-v2/src/lib/ui/mcp/panel/connector/usePanelTabs.tsx +++ b/libs/designer-v2/src/lib/ui/mcp/panel/connector/usePanelTabs.tsx @@ -51,7 +51,7 @@ export const useMcpConnectorPanelTabs = (): McpPanelTabProps[] => { return selectedOperations.some((operationId) => { const nodeId = operationId; const referenceKey = connectionsMapping[nodeId]; - return referenceKey && connectionReferences[referenceKey]; + return typeof referenceKey === 'string' && connectionReferences[referenceKey]; }); }, [selectedOperations, connectionsMapping, connectionReferences]); diff --git a/libs/designer-v2/src/lib/ui/mcp/parameters/ParameterEditor.tsx b/libs/designer-v2/src/lib/ui/mcp/parameters/ParameterEditor.tsx index 981a2ba4380..e1f0a75e866 100644 --- a/libs/designer-v2/src/lib/ui/mcp/parameters/ParameterEditor.tsx +++ b/libs/designer-v2/src/lib/ui/mcp/parameters/ParameterEditor.tsx @@ -52,12 +52,15 @@ export const ParameterEditor = ({ }: ParameterEditorProps) => { const styles = useEditOperationStyles(); const dispatch = useDispatch(); - const { operationInfo, reference, nodeInputs, dependencies } = useSelector((state: RootState) => ({ - operationInfo: state.operations.operationInfo[operationId], - reference: state.connection.connectionReferences[state.connection.connectionsMapping[operationId] ?? ''], - nodeInputs: state.operations.inputParameters[operationId], - dependencies: state.operations.dependencies[operationId], - })); + const { operationInfo, reference, nodeInputs, dependencies } = useSelector((state: RootState) => { + const referenceKey = state.connection.connectionsMapping[operationId]; + return { + operationInfo: state.operations.operationInfo[operationId], + reference: typeof referenceKey === 'string' ? state.connection.connectionReferences[referenceKey] : undefined, + nodeInputs: state.operations.inputParameters[operationId], + dependencies: state.operations.dependencies[operationId], + }; + }); const displayNameResult = useConnectorName(operationInfo); const onComboboxMenuOpen = useCallback((): void => { diff --git a/libs/designer-v2/src/lib/ui/mcp/parameters/__test__/ParameterEditor.spec.tsx b/libs/designer-v2/src/lib/ui/mcp/parameters/__test__/ParameterEditor.spec.tsx new file mode 100644 index 00000000000..daf584ad8ed --- /dev/null +++ b/libs/designer-v2/src/lib/ui/mcp/parameters/__test__/ParameterEditor.spec.tsx @@ -0,0 +1,298 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DynamicLoadStatus } from '@microsoft/designer-ui'; +import { ParameterEditor } from '../ParameterEditor'; +import * as parameterHelpers from '../../../../core/utils/parameters/helper'; +import { createLiteralValueSegment } from '../../../../core/utils/parameters/segment'; +import { + createMcpHarness, + createMcpState, + createParameter, + expressionMapping, + reference, +} from '../../../../core/state/mcp/__test__/fixtures'; + +vi.mock('../../../../core/state/selectors/actionMetadataSelector', () => ({ + useConnectorName: () => ({ result: 'SQL Server' }), +})); + +// These interactive leaf adapters exercise the real MCP editor's wiring, without +// coupling this unit to each shared editor's own Lexical/table/authentication UI. +vi.mock('@microsoft/designer-ui', async (importOriginal) => { + const actual = await importOriginal(); + const { useState } = await import('react'); + const makeEditor = (name: string) => + function EditorAdapter(props: any) { + const [result, setResult] = useState(''); + return ( +
+ { + const change = { value: [{ id: 'edited', type: 'literal', value: event.target.value }] }; + if (name === 'string') { + props.onChange?.(); + } else { + (props.onChange ?? props.editorBlur)?.(change); + } + }} + onBlur={(event) => props.editorBlur?.({ value: [{ id: 'edited', type: 'literal', value: event.target.value }] })} + /> + {Array.isArray(props.options) && ( +
    + {props.options.map((option: any) => ( +
  • {option.displayName}
  • + ))} +
+ )} + {props.isLoading && Loading choices} + {props.errorDetails && {props.errorDetails.message}} + {props.onMenuOpen && ( + + )} + {props.castParameter && ( + + )} + {props.pickerCallbacks && ( + <> + + + + + )} + {result} +
+ ); + }; + return { + ...actual, + ArrayEditor: makeEditor('array'), + AuthenticationEditor: makeEditor('authentication'), + DictionaryEditor: makeEditor('dictionary'), + DropdownEditor: makeEditor('dropdown'), + Combobox: makeEditor('combobox'), + FilePickerEditor: makeEditor('filepicker'), + HTMLEditor: makeEditor('html'), + SchemaEditor: makeEditor('schema'), + TableEditor: makeEditor('table'), + StringEditor: makeEditor('string'), + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(parameterHelpers, 'loadDynamicValuesForParameter').mockResolvedValue(undefined); + vi.spyOn(parameterHelpers, 'loadDynamicTreeItemsForParameter').mockResolvedValue(undefined); +}); +afterEach(cleanup); + +const renderEditor = (parameter = createParameter(), state = createMcpState()) => { + const onParameterValueChange = vi.fn(); + const onParameterVisibilityUpdate = vi.fn(); + const harness = createMcpHarness(state); + return { + ...render( + , + harness + ), + ...harness, + onParameterValueChange, + onParameterVisibilityUpdate, + }; +}; + +describe('MCP parameter editor', () => { + it.each(['array', 'authentication', 'dictionary', 'dropdown', 'combobox', 'filepicker', 'html', 'schema', 'table'])( + 'renders the %s editor and forwards user edits', + (editor) => { + const { onParameterValueChange } = renderEditor(createParameter({ editor: editor.toUpperCase() })); + expect(screen.getByRole('region', { name: `${editor} editor` })).toBeInTheDocument(); + const input = screen.getByRole('textbox', { name: `${editor} value` }); + expect(input).toHaveValue('original'); + fireEvent.change(input, { target: { value: 'changed' } }); + expect(onParameterValueChange).toHaveBeenCalledWith({ value: [{ id: 'edited', type: 'literal', value: 'changed' }] }); + } + ); + + it.each([undefined, 'unrecognized'])('falls back to the string editor for %s and commits on blur rather than typing', (editor) => { + const { onParameterValueChange, onParameterVisibilityUpdate } = renderEditor(createParameter({ editor })); + const input = screen.getByRole('textbox', { name: 'string value' }); + expect(screen.getByRole('region', { name: 'string editor' })).toHaveAttribute('data-tokens', 'false'); + fireEvent.change(input, { target: { value: 'manual value' } }); + expect(onParameterVisibilityUpdate).toHaveBeenCalledOnce(); + expect(onParameterValueChange).not.toHaveBeenCalled(); + fireEvent.blur(input); + expect(onParameterValueChange).toHaveBeenCalledWith({ value: [{ id: 'edited', type: 'literal', value: 'manual value' }] }); + }); + + it.each(['concrete', 'expression', 'null', 'missing', 'dangling reference'] as const)( + 'passes only concrete references to dynamic value and tree loaders (%s)', + (kind) => { + const state = createMcpState(); + if (kind === 'missing') { + delete state.connection.connectionsMapping.Query; + } else { + state.connection.connectionsMapping.Query = + kind === 'expression' ? expressionMapping : kind === 'null' ? null : kind === 'concrete' ? 'Sql' : 'Unknown'; + } + const parameter = createParameter({ editor: 'combobox', dynamicData: { status: DynamicLoadStatus.NOTSTARTED } }); + const { rerender, store } = renderEditor(parameter, state); + fireEvent.click(screen.getByRole('button', { name: 'Open choices' })); + const expectedReference = kind === 'concrete' ? reference : undefined; + expect(parameterHelpers.loadDynamicValuesForParameter).toHaveBeenCalledExactlyOnceWith( + 'Query', + 'default', + 'body', + state.operations.operationInfo.Query, + expectedReference, + state.operations.inputParameters.Query, + state.operations.dependencies.Query, + true, + store.dispatch, + {}, + {} + ); + rerender( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Browse root' })); + fireEvent.click(screen.getByRole('button', { name: 'Open folder' })); + for (const selectedItem of [undefined, { id: 'folder' }]) { + expect(parameterHelpers.loadDynamicTreeItemsForParameter).toHaveBeenCalledWith( + 'Query', + 'default', + 'body', + selectedItem, + state.operations.operationInfo.Query, + expectedReference, + state.operations.inputParameters.Query, + state.operations.dependencies.Query, + true, + store.dispatch, + {}, + {} + ); + } + expect(parameterHelpers.loadDynamicTreeItemsForParameter).toHaveBeenCalledTimes(2); + } + ); + + it.each([DynamicLoadStatus.LOADING, DynamicLoadStatus.SUCCEEDED, undefined])( + 'does not reload dynamic values when status is %s', + (status) => { + renderEditor(createParameter({ editor: 'combobox', dynamicData: status ? { status } : undefined })); + fireEvent.click(screen.getByRole('button', { name: 'Open choices' })); + expect(parameterHelpers.loadDynamicValuesForParameter).not.toHaveBeenCalled(); + if (status === DynamicLoadStatus.LOADING) { + expect(screen.getByText('Loading choices')).toBeInTheDocument(); + } + } + ); + + it('shows failed dynamic choices and retries them on menu open', () => { + renderEditor( + createParameter({ + editor: 'combobox', + editorOptions: { options: [{ displayName: 'Saved choice', value: 'saved' }], multiSelect: true, serialization: { delimiter: ',' } }, + dynamicData: { status: DynamicLoadStatus.FAILED, error: { message: 'Choices unavailable' } }, + }) + ); + expect(screen.getByRole('alert')).toHaveTextContent('Choices unavailable'); + expect(screen.getByText('Saved choice')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Open choices' })); + expect(parameterHelpers.loadDynamicValuesForParameter).toHaveBeenCalledOnce(); + }); + + it('uses real picker metadata to display and select a file, including loading/error presentation', () => { + const state = createMcpState(); + state.operations.dependencies.Query.inputs['inputs.$.body'] = { + filePickerInfo: { fullTitlePath: 'title', valuePath: 'id' }, + } as any; + renderEditor( + createParameter({ + editor: 'filepicker', + dynamicData: { status: DynamicLoadStatus.LOADING, error: { message: 'Last browse failed' } }, + editorViewModel: { displayValue: 'Existing file' }, + }), + state + ); + expect(screen.getByText('Loading choices')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('Last browse failed'); + fireEvent.click(screen.getByRole('button', { name: 'Select file' })); + expect(screen.getByText('SQL Server | Monthly report | /reports/monthly.csv')).toBeInTheDocument(); + }); + + it('casts edited array segments with the normal serializer instead of retaining the old value', () => { + const spy = vi.spyOn(parameterHelpers, 'parameterValueToString'); + renderEditor(createParameter({ editor: 'array', type: 'array', value: [createLiteralValueSegment('["old"]')] })); + fireEvent.click(screen.getByRole('button', { name: 'Serialize array' })); + expect(screen.getByText('["edited"]')).toBeInTheDocument(); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ value: [{ id: 'cast', type: 'literal', value: '["edited"]' }] }), + false, + {}, + true + ); + }); + + it('returns an empty cast value when the serializer produces no value', () => { + vi.spyOn(parameterHelpers, 'parameterValueToString').mockReturnValue(undefined); + renderEditor(createParameter({ editor: 'array' })); + fireEvent.click(screen.getByRole('button', { name: 'Serialize array' })); + expect(screen.getByRole('status')).toBeEmptyDOMElement(); + }); + + it('preserves dropdown option labels and dictionary readonly configuration', () => { + const { rerender } = renderEditor( + createParameter({ editor: 'dropdown', editorOptions: { options: [{ displayName: 'First choice', value: 1 }] } }) + ); + expect(screen.getByText('First choice')).toBeInTheDocument(); + rerender( + + ); + expect(screen.getByRole('textbox', { name: 'dictionary value' })).toHaveAttribute('readonly'); + }); +}); diff --git a/libs/designer-v2/src/lib/ui/mcp/parameters/__test__/parameterfield.spec.tsx b/libs/designer-v2/src/lib/ui/mcp/parameters/__test__/parameterfield.spec.tsx new file mode 100644 index 00000000000..74381674bcd --- /dev/null +++ b/libs/designer-v2/src/lib/ui/mcp/parameters/__test__/parameterfield.spec.tsx @@ -0,0 +1,201 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import type { ComponentProps } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ParameterField } from '../parameterfield'; +import * as parameterHelpers from '../../../../core/utils/parameters/helper'; +import { + createMcpHarness, + createMcpState, + createParameter, + expressionMapping, + reference, +} from '../../../../core/state/mcp/__test__/fixtures'; + +vi.mock('../ParameterEditor', () => ({ + ParameterEditor: ({ parameter, onParameterValueChange }: ComponentProps) => ( +
+ onParameterValueChange({ value: [{ id: 'edited', type: 'literal', value: event.target.value }] })} + /> + +
+ ), +})); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(parameterHelpers, 'updateParameterAndDependencies').mockImplementation( + (payload) => ({ type: 'test/updateParameter', payload }) as any + ); +}); +afterEach(cleanup); + +const renderField = (overrides: Partial> = {}, state = createMcpState()) => { + const props: ComponentProps = { + operationId: 'Query', + groupId: 'default', + parameter: createParameter({ preservedValue: 'old imported value' }), + parameterInputType: 'user', + parameterError: 'Value is required', + onParameterVisibilityUpdate: vi.fn(), + onParameterInputTypeChange: vi.fn(), + handleRemoveConditionalParameter: vi.fn(), + removeParameterError: vi.fn(), + ...overrides, + }; + const harness = createMcpHarness(state); + return { ...render(, harness), ...harness, props }; +}; +const selectInputType = (name: string) => { + fireEvent.click(screen.getByRole('combobox')); + fireEvent.click(screen.getByRole('option', { name })); +}; + +describe('MCP parameter field', () => { + it.each(['concrete', 'expression', 'null', 'missing', 'dangling reference'] as const)( + 'dispatches user edits with only a concrete connection reference (%s)', + (kind) => { + const state = createMcpState(); + if (kind === 'missing') { + delete state.connection.connectionsMapping.Query; + } else { + state.connection.connectionsMapping.Query = + kind === 'expression' ? expressionMapping : kind === 'null' ? null : kind === 'concrete' ? 'Sql' : 'Unknown'; + } + const { props, actions } = renderField({}, state); + expect(screen.getByText('Value is required')).toBeInTheDocument(); + fireEvent.change(screen.getByRole('textbox', { name: 'Parameter value' }), { target: { value: 'new value' } }); + expect(actions).toEqual([ + { + type: 'test/updateParameter', + payload: { + nodeId: 'Query', + groupId: 'default', + parameterId: 'body', + properties: { value: [{ id: 'edited', type: 'literal', value: 'new value' }], preservedValue: undefined }, + isTrigger: false, + operationInfo: state.operations.operationInfo.Query, + connectionReference: kind === 'concrete' ? reference : undefined, + nodeInputs: state.operations.inputParameters.Query, + dependencies: state.operations.dependencies.Query, + updateTokenMetadata: false, + loadDynamicOutputs: false, + loadDefaultValues: false, + }, + }, + ]); + expect(props.removeParameterError).toHaveBeenCalledExactlyOnceWith('body'); + expect(props.onParameterVisibilityUpdate).toHaveBeenCalledOnce(); + } + ); + + it('keeps required errors when a user clears the value, but drops preserved imported values', () => { + const { props, actions } = renderField(); + fireEvent.change(screen.getByRole('textbox', { name: 'Parameter value' }), { target: { value: '' } }); + expect(props.removeParameterError).not.toHaveBeenCalled(); + expect(props.onParameterVisibilityUpdate).toHaveBeenCalledOnce(); + expect(actions[0]).toMatchObject({ + payload: { properties: { value: [{ value: '' }], preservedValue: undefined } }, + }); + expect((actions[0].payload as any).properties).not.toHaveProperty('editorViewModel'); + }); + + it('updates editor view models when structured editors supply them', () => { + const { actions, props } = renderField({ parameter: createParameter({ editor: 'dictionary' }) }); + fireEvent.click(screen.getByRole('button', { name: 'Edit structured value' })); + expect(actions[0]).toMatchObject({ + payload: { + properties: { + editorViewModel: { items: [{ name: 'edited' }] }, + value: [{ value: '{"name":"edited"}' }], + preservedValue: undefined, + }, + }, + }); + expect(props.removeParameterError).toHaveBeenCalledWith('body'); + }); + + it('switches model inputs to user inputs without resetting a value', () => { + const { props, actions, rerender } = renderField({ parameterInputType: 'model', parameterError: undefined }); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.queryByText('Provided value')).not.toBeInTheDocument(); + selectInputType('User'); + expect(props.onParameterInputTypeChange).toHaveBeenCalledWith('body', 'user'); + expect(actions).toEqual([]); + rerender(); + expect(screen.getByRole('textbox', { name: 'Parameter value' })).toHaveValue('original'); + expect(screen.getByText('Provided value')).toBeInTheDocument(); + }); + + it.each(['concrete', 'expression', 'missing'] as const)( + 'switches to model, clears errors and values, and never promotes %s mappings', + (kind) => { + const state = createMcpState(); + if (kind === 'missing') { + delete state.connection.connectionsMapping.Query; + } else if (kind === 'expression') { + state.connection.connectionsMapping.Query = expressionMapping; + } + const { props, actions, rerender } = renderField({}, state); + selectInputType('Model'); + expect(props.onParameterInputTypeChange).toHaveBeenCalledWith('body', 'model'); + expect(props.removeParameterError).toHaveBeenCalledExactlyOnceWith('body'); + expect(props.onParameterVisibilityUpdate).toHaveBeenCalledOnce(); + expect(actions).toHaveLength(1); + expect(actions[0]).toMatchObject({ + type: 'test/updateParameter', + payload: { + connectionReference: kind === 'concrete' ? reference : undefined, + properties: { value: [{ type: 'literal', value: '' }], preservedValue: undefined }, + }, + }); + rerender(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.queryByText('Value is required')).not.toBeInTheDocument(); + } + ); + + it('prevents changing a locked input type', () => { + const { props, actions } = renderField({ disableInputTypeChange: true }); + expect(screen.getByRole('combobox')).toBeDisabled(); + fireEvent.click(screen.getByRole('combobox')); + expect(screen.queryByRole('option')).not.toBeInTheDocument(); + expect(props.onParameterInputTypeChange).not.toHaveBeenCalled(); + expect(actions).toEqual([]); + }); + + it('labels dynamic parameters and removes conditional parameters without changing their values', () => { + const { props, actions } = renderField({ + parameter: createParameter({ info: { isDynamic: true } as any, placeholder: 'Pick a payload' }), + isConditional: true, + }); + expect(screen.getByText('Payload')).toBeInTheDocument(); + expect(screen.getByText('Dynamic parameter')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Remove parameter' })); + expect(props.handleRemoveConditionalParameter).toHaveBeenCalledExactlyOnceWith('body'); + expect(actions).toEqual([]); + }); + + it.each(['array', 'dictionary', 'html', 'table', undefined])( + 'keeps %s user parameters editable without conditional controls', + (editor) => { + renderField({ parameter: createParameter({ editor }), parameterError: undefined }); + expect(screen.getByRole('textbox', { name: 'Parameter value' })).toBeInTheDocument(); + expect(screen.queryByText('Dynamic parameter')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Remove parameter' })).not.toBeInTheDocument(); + } + ); +}); diff --git a/libs/designer-v2/src/lib/ui/mcp/parameters/parameterfield.tsx b/libs/designer-v2/src/lib/ui/mcp/parameters/parameterfield.tsx index 3ce9c9e23d0..d7bd74a7a03 100644 --- a/libs/designer-v2/src/lib/ui/mcp/parameters/parameterfield.tsx +++ b/libs/designer-v2/src/lib/ui/mcp/parameters/parameterfield.tsx @@ -56,12 +56,15 @@ export const ParameterField = ({ const dispatch = useDispatch(); const styles = useEditOperationStyles(); - const { operationInfo, reference, nodeInputs, dependencies } = useSelector((state: RootState) => ({ - operationInfo: state.operations.operationInfo[operationId], - reference: state.connection.connectionReferences[state.connection.connectionsMapping[operationId] ?? ''], - nodeInputs: state.operations.inputParameters[operationId], - dependencies: state.operations.dependencies[operationId], - })); + const { operationInfo, reference, nodeInputs, dependencies } = useSelector((state: RootState) => { + const referenceKey = state.connection.connectionsMapping[operationId]; + return { + operationInfo: state.operations.operationInfo[operationId], + reference: typeof referenceKey === 'string' ? state.connection.connectionReferences[referenceKey] : undefined, + nodeInputs: state.operations.inputParameters[operationId], + dependencies: state.operations.dependencies[operationId], + }; + }); const INTL_TEXT = { removeParamText: intl.formatMessage({ diff --git a/libs/designer-v2/src/lib/ui/panel/connectionsPanel/__test__/connectionsPanel.spec.tsx b/libs/designer-v2/src/lib/ui/panel/connectionsPanel/__test__/connectionsPanel.spec.tsx index 674dd11fb02..c4725f0c0be 100644 --- a/libs/designer-v2/src/lib/ui/panel/connectionsPanel/__test__/connectionsPanel.spec.tsx +++ b/libs/designer-v2/src/lib/ui/panel/connectionsPanel/__test__/connectionsPanel.spec.tsx @@ -5,9 +5,11 @@ import { ConnectionPanel } from '../connectionsPanel'; import { autoCreateConnectionIfPossible, closeConnectionsFlow } from '../../../../core/actions/bjsworkflow/connections'; import { updateNodeConnection, useOperationInfo, useOperationPanelSelectedNodeId } from '../../../../core'; import { useConnectionsForConnector } from '../../../../core/queries/connections'; -import { useConnectionRefs, useConnectorByNodeId } from '../../../../core/state/connection/connectionSelector'; -import { useIsCreatingConnection } from '../../../../core/state/panel/panelSelectors'; +import { useConnectionRefs, useConnectorByNodeId, useNodeConnectionMapping } from '../../../../core/state/connection/connectionSelector'; +import { useMonitoringView, useReadOnly } from '../../../../core/state/designerOptions/designerOptionsSelectors'; +import { useConnectionPanelSelectedNodeIds, useIsCreatingConnection } from '../../../../core/state/panel/panelSelectors'; import { setIsCreatingConnection } from '../../../../core/state/panel/panelSlice'; +import { useConnectionExpressionEnabled } from '../selectConnection/connectionExpression'; const mocks = vi.hoisted(() => ({ dispatch: vi.fn(), @@ -70,12 +72,23 @@ vi.mock('../../../../core/queries/connections', () => ({ vi.mock('../../../../core/state/connection/connectionSelector', () => ({ useConnectionRefs: vi.fn(), useConnectorByNodeId: vi.fn(), + useNodeConnectionMapping: vi.fn(), +})); + +vi.mock('../../../../core/state/designerOptions/designerOptionsSelectors', () => ({ + useReadOnly: vi.fn(), + useMonitoringView: vi.fn(), })); vi.mock('../../../../core/state/panel/panelSelectors', () => ({ + useConnectionPanelSelectedNodeIds: vi.fn(), useIsCreatingConnection: vi.fn(), })); +vi.mock('../selectConnection/connectionExpression', () => ({ + useConnectionExpressionEnabled: vi.fn(), +})); + vi.mock('../../../../core/state/panel/panelSlice', () => ({ setIsCreatingConnection: vi.fn((isCreating: boolean) => ({ payload: isCreating, type: 'panel/setIsCreatingConnection' })), })); @@ -131,6 +144,11 @@ describe('ConnectionPanel (designer-v2)', () => { beforeEach(() => { vi.clearAllMocks(); (useOperationPanelSelectedNodeId as Mock).mockReturnValue('node-id'); + (useConnectionPanelSelectedNodeIds as Mock).mockReturnValue(['node-id']); + (useNodeConnectionMapping as Mock).mockReturnValue(null); + (useConnectionExpressionEnabled as Mock).mockReturnValue(false); + (useReadOnly as Mock).mockReturnValue(false); + (useMonitoringView as Mock).mockReturnValue(false); (useConnectorByNodeId as Mock).mockReturnValue(mockConnector); (useOperationInfo as Mock).mockReturnValue({ connectorId: 'connector-id', operationId: 'op-id' }); (useConnectionRefs as Mock).mockReturnValue({ referenceOne: {}, referenceTwo: {} }); @@ -139,6 +157,116 @@ describe('ConnectionPanel (designer-v2)', () => { setConnectionsQuery(); }); + describe('runtime-expression auto-create gate', () => { + const expectSelectionWithoutMutation = () => { + expect(screen.getByRole('heading', { name: 'Change connection' })).toBeInTheDocument(); + expect(screen.getByTestId('select-connection-wrapper')).toBeInTheDocument(); + expect(screen.queryByTestId('create-connection-wrapper')).not.toBeInTheDocument(); + expect(autoCreateConnectionIfPossible).not.toHaveBeenCalled(); + expect(updateNodeConnection).not.toHaveBeenCalled(); + expect(closeConnectionsFlow).not.toHaveBeenCalled(); + expect(setIsCreatingConnection).not.toHaveBeenCalled(); + expect(mocks.dispatch).not.toHaveBeenCalled(); + }; + + it('does not auto-create or force Create connection when new runtime authoring is eligible and no connections exist', () => { + (useConnectorByNodeId as Mock).mockReturnValue({ ...mockConnector, id: '/serviceProviders/sql' }); + (useOperationInfo as Mock).mockReturnValue({ + type: 'ServiceProvider', + connectorId: '/serviceProviders/sql', + operationId: 'executeQuery', + }); + (useConnectionExpressionEnabled as Mock).mockReturnValue(true); + setConnectionsQuery({ isLoading: true }); + const { rerender } = render(); + + setConnectionsQuery({ data: [], isLoading: false }); + rerender(); + + expect(useConnectionExpressionEnabled).toHaveBeenCalledWith(['node-id']); + expectSelectionWithoutMutation(); + }); + + it('preserves an imported expression in an unsupported context using the connection-panel selection', () => { + const expressionMapping = { kind: 'expression', expression: "@outputs('Resolve_Connection')" }; + (useConnectionPanelSelectedNodeIds as Mock).mockReturnValue(['runtime-node']); + (useNodeConnectionMapping as Mock).mockImplementation((id: string) => (id === 'runtime-node' ? expressionMapping : 'static-ref')); + (autoCreateConnectionIfPossible as Mock).mockImplementation(({ applyNewConnection, onSuccess }) => { + applyNewConnection(newConnection); + onSuccess(); + return Promise.resolve(); + }); + const { rerender } = render(); + setConnectionsQuery({ data: [] }); + rerender(); + + expect(useConnectionExpressionEnabled).toHaveBeenCalledWith(['runtime-node']); + expect(useNodeConnectionMapping).toHaveBeenCalledWith('runtime-node'); + expectSelectionWithoutMutation(); + }); + + it.each([ + ['read-only', null], + ['read-only', 'static-ref'], + ['monitoring', null], + ['monitoring', 'static-ref'], + ] as const)('does not mutate connections in %s mode when the mapping is %s', (mode, mapping) => { + ((mode === 'monitoring' ? useMonitoringView : useReadOnly) as Mock).mockReturnValue(true); + (useNodeConnectionMapping as Mock).mockReturnValue(mapping); + (autoCreateConnectionIfPossible as Mock).mockImplementation(({ onManualConnectionCreation }) => { + onManualConnectionCreation(); + return Promise.resolve(); + }); + + render(); + + expectSelectionWithoutMutation(); + }); + + it.each([ + ['unsupported connector', 'connector-id'], + ['Consumption ServiceProvider', '/serviceProviders/sql'], + ])('retains static auto-create for %s when no runtime expression is mapped', (_case, connectorId) => { + const connector = { ...mockConnector, id: connectorId }; + (useConnectorByNodeId as Mock).mockReturnValue(connector); + (useOperationInfo as Mock).mockReturnValue({ + connectorId, + operationId: 'op-id', + type: connectorId.startsWith('/serviceProviders') ? 'ServiceProvider' : 'ApiConnection', + }); + (useNodeConnectionMapping as Mock).mockReturnValue('static-ref'); + (autoCreateConnectionIfPossible as Mock).mockImplementation(({ applyNewConnection, onSuccess }) => { + applyNewConnection(newConnection); + onSuccess(); + return Promise.resolve(); + }); + + render(); + + expect(autoCreateConnectionIfPossible).toHaveBeenCalledTimes(1); + expect(updateNodeConnection).toHaveBeenCalledWith({ nodeId: 'node-id', connection: newConnection, connector }); + expect(mocks.dispatch).toHaveBeenCalledWith({ + type: 'connections/updateNodeConnection', + payload: { nodeId: 'node-id', connection: newConnection, connector }, + }); + expect(closeConnectionsFlow).toHaveBeenCalledWith({ nodeId: 'node-id' }); + }); + + it('does not latch the reentry guard while expression selection blocks auto-create', async () => { + (useConnectionExpressionEnabled as Mock).mockReturnValue(true); + (autoCreateConnectionIfPossible as Mock).mockReturnValue(new Promise(() => {})); + const { rerender } = render(); + expectSelectionWithoutMutation(); + + (useConnectionExpressionEnabled as Mock).mockReturnValue(false); + rerender(); + await waitFor(() => expect(autoCreateConnectionIfPossible).toHaveBeenCalledTimes(1)); + setConnectionsQuery({ data: [] }); + rerender(); + expect(autoCreateConnectionIfPossible).toHaveBeenCalledTimes(1); + }); + }); + describe('panel rendering', () => { it('renders the default header and AllConnections when no node is selected', () => { (useOperationPanelSelectedNodeId as Mock).mockReturnValue(undefined); diff --git a/libs/designer-v2/src/lib/ui/panel/connectionsPanel/allConnections/allConnections.tsx b/libs/designer-v2/src/lib/ui/panel/connectionsPanel/allConnections/allConnections.tsx index 63090b31edb..78e0295fe9e 100644 --- a/libs/designer-v2/src/lib/ui/panel/connectionsPanel/allConnections/allConnections.tsx +++ b/libs/designer-v2/src/lib/ui/panel/connectionsPanel/allConnections/allConnections.tsx @@ -5,13 +5,25 @@ import { Accordion, AccordionItem, type AccordionToggleEventHandler } from '@flu import { getRecordEntry } from '@microsoft/logic-apps-shared'; import { useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { setConnectionPanelExpandedConnectorIds } from '../../../../core/state/panel/panelSlice'; +import { setConnectionPanelExpandedConnectorIds, openPanel } from '../../../../core/state/panel/panelSlice'; import { AllConnectionsEmptyState } from './allConnectionsEmptyState'; +import { isExpressionConnectionMapping } from '../../../../common/models/workflow'; +import { Button, Text, makeStyles, tokens } from '@fluentui/react-components'; +import { useIntl } from 'react-intl'; + +const useStyles = makeStyles({ + runtimeConnections: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalS, padding: tokens.spacingHorizontalM }, + expression: { display: 'block', fontFamily: tokens.fontFamilyMonospace, overflowWrap: 'anywhere' }, +}); export const AllConnections = () => { const dispatch = useDispatch(); + const intl = useIntl(); + const styles = useStyles(); const connectionMapping = useConnectionMapping(); const connectionReferences = useConnectionRefs(); + const actionNames = useSelector((state: RootState) => state.workflow.idReplacements); + const hasRuntimeConnections = Object.values(connectionMapping).some(isExpressionConnectionMapping); const allOperationInfo = useSelector((state: RootState) => state.operations.operationInfo); @@ -20,7 +32,7 @@ export const AllConnections = () => { const connections: any = {}; // const connections: any = JSON.parse(JSON.stringify(connectionReferences)); for (const [nodeId, connectionReference] of Object.entries(connectionMapping)) { - if (!connectionReference) { + if (typeof connectionReference !== 'string' || !connectionReference) { continue; // Skip if no connection reference } if (!connections[connectionReference]) { @@ -72,18 +84,41 @@ export const AllConnections = () => { const hasConnections = Object.keys(groupedConnections).length > 0; - if (!hasConnections) { + if (!hasConnections && !hasRuntimeConnections) { return ; } return ( - - {Object.entries(groupedConnections).map(([apiId, connectionRefs]) => ( - - - - ))} - + <> + {hasRuntimeConnections ? ( +
+ + {intl.formatMessage({ + defaultMessage: 'Connections selected at runtime', + id: 'DI8VPY', + description: 'Heading for actions whose connections are selected by expressions', + })} + + {Object.entries(connectionMapping).map(([nodeId, mapping]) => + isExpressionConnectionMapping(mapping) ? ( +
+ + {mapping.expression} +
+ ) : null + )} +
+ ) : null} + + {Object.entries(groupedConnections).map(([apiId, connectionRefs]) => ( + + + + ))} + + ); }; diff --git a/libs/designer-v2/src/lib/ui/panel/connectionsPanel/connectionsPanel.tsx b/libs/designer-v2/src/lib/ui/panel/connectionsPanel/connectionsPanel.tsx index e16cab16747..12a0d38f6b8 100644 --- a/libs/designer-v2/src/lib/ui/panel/connectionsPanel/connectionsPanel.tsx +++ b/libs/designer-v2/src/lib/ui/panel/connectionsPanel/connectionsPanel.tsx @@ -2,12 +2,15 @@ import { XLargeText } from '@microsoft/designer-ui'; import type { AppDispatch } from '../../../core'; import { updateNodeConnection, useOperationInfo, useOperationPanelSelectedNodeId } from '../../../core'; import { useConnectionsForConnector } from '../../../core/queries/connections'; -import { useConnectionRefs, useConnectorByNodeId } from '../../../core/state/connection/connectionSelector'; -import { useIsCreatingConnection } from '../../../core/state/panel/panelSelectors'; +import { useConnectionRefs, useConnectorByNodeId, useNodeConnectionMapping } from '../../../core/state/connection/connectionSelector'; +import { useMonitoringView, useReadOnly } from '../../../core/state/designerOptions/designerOptionsSelectors'; +import { useConnectionPanelSelectedNodeIds, useIsCreatingConnection } from '../../../core/state/panel/panelSelectors'; import { setIsCreatingConnection } from '../../../core/state/panel/panelSlice'; import { AllConnections } from './allConnections/allConnections'; import { CreateConnectionWrapper } from './createConnection/createConnectionWrapper'; import { SelectConnectionWrapper } from './selectConnection/selectConnection'; +import { useConnectionExpressionEnabled } from './selectConnection/connectionExpression'; +import { isExpressionConnectionMapping } from '../../../common/models/workflow'; import { Button } from '@fluentui/react-components'; import { bundleIcon, Dismiss24Filled, Dismiss24Regular } from '@fluentui/react-icons'; import type { CommonPanelProps } from '@microsoft/designer-ui'; @@ -22,6 +25,13 @@ const CloseIcon = bundleIcon(Dismiss24Filled, Dismiss24Regular); export const ConnectionPanel = (props: CommonPanelProps) => { const dispatch = useDispatch(); const selectedNodeId = useOperationPanelSelectedNodeId(); + const selectedNodeIds = useConnectionPanelSelectedNodeIds(); + const isReadOnly = useReadOnly(); + const isMonitoringView = useMonitoringView(); + const readOnly = isReadOnly || isMonitoringView; + const expressionEnabled = useConnectionExpressionEnabled(selectedNodeIds); + const mapping = useNodeConnectionMapping(selectedNodeIds?.[0]); + const showExpressionSelection = expressionEnabled || isExpressionConnectionMapping(mapping); const connector = useConnectorByNodeId(selectedNodeId); const operationInfo = useOperationInfo(selectedNodeId); const references = useConnectionRefs(); @@ -38,6 +48,8 @@ export const ConnectionPanel = (props: CommonPanelProps) => { useEffect(() => { if ( + !readOnly && + !showExpressionSelection && selectedNodeId && connector && !connectionQuery.isLoading && @@ -65,7 +77,18 @@ export const ConnectionPanel = (props: CommonPanelProps) => { isAutoCreatingRef.current = false; }); } - }, [connectionQuery.isError, connectionQuery.isLoading, connections, connector, dispatch, operationInfo, references, selectedNodeId]); + }, [ + connectionQuery.isError, + connectionQuery.isLoading, + connections, + connector, + dispatch, + operationInfo, + readOnly, + references, + selectedNodeId, + showExpressionSelection, + ]); const panelStatus = useMemo(() => { if (!selectedNodeId) { diff --git a/libs/designer-v2/src/lib/ui/panel/connectionsPanel/selectConnection/__test__/connectionExpression.spec.tsx b/libs/designer-v2/src/lib/ui/panel/connectionsPanel/selectConnection/__test__/connectionExpression.spec.tsx new file mode 100644 index 00000000000..177ab89dc97 --- /dev/null +++ b/libs/designer-v2/src/lib/ui/panel/connectionsPanel/selectConnection/__test__/connectionExpression.spec.tsx @@ -0,0 +1,549 @@ +import '@testing-library/jest-dom/vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { IntlProvider } from 'react-intl'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + connectionExpressionEditor, +} from '../../../../../../../../designer-ui/__test__/connection-expression-editor-helper'; +import { SelectConnectionWrapper } from '../selectConnection'; +import { AllConnections } from '../../allConnections/allConnections'; +import { ConnectionDisplay } from '../../../nodeDetailsPanel/tabs/parametersTab/connectionDisplay'; +import { createValueSegmentFromToken } from '../../../../../core/utils/tokens'; + +const mocks = vi.hoisted(() => ({ + state: {} as any, + dispatch: vi.fn(() => ({ unwrap: () => Promise.resolve() })), + autoCreate: vi.fn(), + setupConnection: vi.fn(), + expressionUpdate: vi.fn((payload) => ({ type: 'expression', payload })), + staticUpdate: vi.fn((payload) => ({ type: 'static', payload })), + query: { data: [] as any[], isLoading: false, isError: false }, + selectedNodeIds: ['action'], + connectorQuery: vi.fn(() => ({ data: undefined, isFetching: false })), + pickerProps: undefined as any, +})); + +vi.mock('react-redux', () => ({ + useDispatch: () => mocks.dispatch, + useSelector: (selector: (state: any) => unknown) => selector(mocks.state), +})); +vi.mock('../../../../../core', () => ({ + useOperationInfo: () => mocks.state.operations.operationInfo.action, + useConnectionMapping: () => mocks.state.connections.connectionsMapping, + useConnectionRefs: () => mocks.state.connections.connectionReferences, + openPanel: (payload: unknown) => ({ type: 'openPanel', payload }), +})); +vi.mock('../../../../../core/actions/bjsworkflow/connections', () => ({ + autoCreateConnectionIfPossible: mocks.autoCreate, + updateNodeConnectionExpression: mocks.expressionUpdate, + updateNodeConnection: mocks.staticUpdate, +})); +vi.mock('../../../../../core/state/connection/connectionSelector', () => ({ + useConnector: mocks.connectorQuery, + useNodeConnectionMapping: () => mocks.state.connections.connectionsMapping.action, + useNodeConnectionId: () => '/connections/SqlDesign', + useConnectionRefs: () => mocks.state.connections.connectionReferences, + useConnectionRefsByConnectorId: () => [], + useConnectorByNodeId: () => ({ id: '/serviceProviders/sql', name: 'sql' }), + useIsOperationMissingConnection: () => true, +})); +vi.mock('../../../../../core/state/selectors/actionMetadataSelector', () => ({ + useOperationInfo: () => mocks.state.operations.operationInfo.action, + useIsConnectionRequired: () => true, +})); +vi.mock('../../../../../core/state/panel/panelSelectors', () => ({ + useConnectionPanelSelectedNodeIds: () => mocks.selectedNodeIds, + useOperationPanelSelectedNodeId: () => 'action', + usePreviousPanelMode: () => 'Operation', +})); +vi.mock('../../../../../core/state/panel/panelSlice', () => ({ + openPanel: (payload: unknown) => ({ type: 'openPanel', payload }), + setIsCreatingConnection: (payload: unknown) => ({ type: 'createPanel', payload }), + setConnectionPanelExpandedConnectorIds: (payload: unknown) => ({ type: 'expandConnectors', payload }), +})); +vi.mock('../../../../../core/state/designerView/designerViewSelectors', () => ({ useIsA2AWorkflow: () => false })); +vi.mock('../../../../../common/hooks/agent', () => ({ useIsAgentSubGraph: () => false })); +vi.mock('../../../../../core/queries/connections', () => ({ useConnectionsForConnector: () => mocks.query })); +vi.mock('../../actionList/actionList', () => ({ ActionList: () => null })); +vi.mock('../../allConnections/connectorConnectionsCard', () => ({ + ConnectorConnectionsCard: () =>
Concrete connections
, +})); +vi.mock('../connectionTable', () => ({ + ConnectionTable: ({ saveSelectionCallback }: any) => ( + + ), +})); +vi.mock('@microsoft/logic-apps-shared', async (importOriginal) => ({ + ...(await importOriginal()), + ConnectionService: () => ({ setupConnectionIfNeeded: mocks.setupConnection }), + LoggerService: () => ({ log: vi.fn() }), +})); +vi.mock('../../../../../core/utils/tokens', () => ({ + getExpressionTokenSections: () => [], + getOutputTokenSections: vi.fn(() => [ + { id: 'parameters', label: 'Parameters', tokens: [{ title: 'ConnectionName', value: "parameters('ConnectionName')", outputInfo: {} }] }, + { + id: 'previous', + label: 'Previous action', + tokens: [ + { title: 'Output', value: "outputs('Previous')", outputInfo: {} }, + { title: 'Array item connection', value: "item()?['connectionName']", outputInfo: { arrayDetails: { parentArrayName: 'rows' } } }, + ], + }, + ]), + createValueSegmentFromToken: vi.fn(async (_nodeId, _parameterId, token) => ({ + id: token.title, + type: 'token', + value: token.value, + token: { key: token.title, title: token.title, tokenType: 'fx', value: token.value }, + })), +})); + +vi.mock('@microsoft/designer-ui', async (importOriginal) => { + const actual = await importOriginal(); + const { INSERT_TOKEN_NODE } = await import('../../../../../../../../designer-ui/src/lib/editor/base/plugins/InsertTokenNode'); + return { + ...actual, + TokenPicker: (props: any) => { + mocks.pickerProps = props; + return ( +
+ {props.tokenGroup.flatMap((group: any) => + group.tokens.map((token: any) => ( + + )) + )} +
+ ); + }, + }; +}); + +const expression = "@if(equals(triggerBody()?['Route'], 'A'), outputs('Previous'), parameters('ConnectionName'))"; +const renderPanel = () => + render( + + + + ); +const edit = async (value: string) => { + await act(async () => { + connectionExpressionEditor.current?.update( + () => { + const paragraph = $createParagraphNode(); + if (value) { + paragraph.append($createTextNode(value)); + } + $getRoot().clear().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true } + ); + }); +}; +const expressionMode = async () => { + fireEvent.click(screen.getByRole('radio', { name: 'Use expression' })); + await waitFor(() => expect(connectionExpressionEditor.current).toBeDefined()); +}; + +beforeEach(() => { + vi.clearAllMocks(); + connectionExpressionEditor.current = undefined; + mocks.selectedNodeIds = ['action']; + mocks.pickerProps = undefined; + mocks.query = { data: [], isLoading: false, isError: false }; + mocks.state = { + designerOptions: { readOnly: false, isMonitoringView: false, hostOptions: {} }, + workflow: { workflowKind: 'stateful', nodesMetadata: { action: { isTrigger: false } }, idReplacements: {} }, + operations: { operationInfo: { action: { type: 'ServiceProvider', connectorId: '/serviceProviders/sql' } } }, + connections: { + connectionsMapping: { action: null }, + connectionReferences: { + SqlDesign: { api: { id: '/serviceProviders/sql' }, connection: { id: '/connections/SqlDesign' }, connectionName: 'Friendly label' }, + sqldesign: { api: { id: '/serviceProviders/sql' }, connection: { id: '/connections/sqldesign' } }, + unrelated: { api: { id: '/serviceProviders/blob' }, connection: { id: '/connections/blob' } }, + }, + }, + tokens: {}, + workflowParameters: {}, + panel: { connectionContent: { expandedConnectorIds: [] } }, + }; +}); +afterEach(cleanup); + +describe('connection expression selection', () => { + it.each<[string, () => void]>([ + [ + 'read-only mode', + () => { + mocks.state.designerOptions.readOnly = true; + }, + ], + [ + 'monitoring mode', + () => { + mocks.state.designerOptions.isMonitoringView = true; + }, + ], + [ + 'missing operation metadata', + () => { + delete mocks.state.operations.operationInfo.action; + }, + ], + [ + 'Consumption', + () => { + mocks.state.workflow.workflowKind = undefined; + }, + ], + [ + 'trigger', + () => { + mocks.state.workflow.nodesMetadata.action.isTrigger = true; + }, + ], + [ + 'API connection', + () => { + mocks.state.operations.operationInfo.action.type = 'ApiConnection'; + }, + ], + [ + 'no selected actions', + () => { + mocks.selectedNodeIds = []; + }, + ], + [ + 'multiple eligible actions', + () => { + mocks.selectedNodeIds = ['action', 'second']; + mocks.state.workflow.nodesMetadata.second = { isTrigger: false }; + mocks.state.operations.operationInfo.second = { type: 'ServiceProvider', connectorId: '/serviceProviders/sql' }; + }, + ], + ])('does not offer expression authoring for %s', (_name, configure) => { + configure(); + renderPanel(); + expect(screen.queryByRole('radio', { name: 'Use expression' })).not.toBeInTheDocument(); + }); + + it.each(['stateful', 'stateless'])( + 'offers expression authoring by default for Standard %s with ordinary host options and no connections', + (workflowKind) => { + mocks.state.workflow.workflowKind = workflowKind; + renderPanel(); + expect(mocks.state.designerOptions.hostOptions).toEqual({}); + expect(screen.getByRole('radio', { name: 'Use expression' })).toBeEnabled(); + expect(screen.getByRole('radio', { name: 'Existing connection' })).toBeChecked(); + expect(mocks.autoCreate).not.toHaveBeenCalled(); + } + ); + + it('does not apply an imported expression to multiple selected actions', () => { + mocks.selectedNodeIds = ['action', 'second']; + mocks.state.workflow.nodesMetadata.second = { isTrigger: false }; + mocks.state.operations.operationInfo.second = { type: 'ServiceProvider', connectorId: '/serviceProviders/sql' }; + mocks.state.connections.connectionsMapping = { + action: { kind: 'expression', expression }, + second: 'SqlDesign', + }; + const originalMappings = structuredClone(mocks.state.connections.connectionsMapping); + renderPanel(); + + expect(screen.getByRole('textbox', { name: 'Connection expression' })).toHaveAttribute('contenteditable', 'false'); + const apply = screen.getByRole('button', { name: 'Apply' }); + expect(apply).toBeDisabled(); + fireEvent.click(apply); + expect(mocks.expressionUpdate).not.toHaveBeenCalled(); + expect(mocks.state.connections.connectionsMapping).toEqual(originalMappings); + }); + + it('keeps expression mode accessible while connection resources are loading', async () => { + mocks.query.isLoading = true; + renderPanel(); + await expressionMode(); + await edit(expression); + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + await waitFor(() => + expect(mocks.expressionUpdate).toHaveBeenCalledWith({ nodeId: 'action', expression, designTimeReferenceKey: undefined }) + ); + expect(mocks.setupConnection).not.toHaveBeenCalled(); + expect(mocks.autoCreate).not.toHaveBeenCalled(); + }); + + it.each([ + "@outputs('Previous')", + "@parameters('ConnectionName')", + "@triggerBody()?['Connection']", + "sql-@{parameters('Tenant')}", + "@items('For_each')?['connectionName']", + ])('applies the authored expression without changing its text: %s', async (value) => { + renderPanel(); + await expressionMode(); + await edit(value); + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + await waitFor(() => + expect(mocks.expressionUpdate).toHaveBeenCalledWith({ nodeId: 'action', expression: value, designTimeReferenceKey: undefined }) + ); + }); + + it.each(['SqlDesign', "@@parameters('ConnectionName')", '@outputs(', ''])('blocks invalid or literal authoring: %s', async (value) => { + renderPanel(); + await expressionMode(); + await edit(value); + expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); + expect(screen.getByText(/Enter a valid workflow expression/)).toBeInTheDocument(); + expect(mocks.expressionUpdate).not.toHaveBeenCalled(); + }); + + it('uses exact reference keys for optional design-time selection and can clear it without changing the expression', async () => { + mocks.state.connections.connectionsMapping.action = { kind: 'expression', expression, designTimeReferenceKey: 'SqlDesign' }; + renderPanel(); + const select = screen.getByRole('combobox', { name: 'Design-time connection (optional)' }); + expect(screen.getByRole('option', { name: 'SqlDesign' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'sqldesign' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'unrelated' })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Friendly label' })).not.toBeInTheDocument(); + fireEvent.change(select, { target: { value: 'sqldesign' } }); + expect(mocks.dispatch).not.toHaveBeenCalled(); + fireEvent.change(select, { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + await waitFor(() => + expect(mocks.expressionUpdate).toHaveBeenCalledWith({ nodeId: 'action', expression, designTimeReferenceKey: undefined }) + ); + }); + + it('cancel discards both the editor draft and design-time selection', async () => { + renderPanel(); + await expressionMode(); + await edit(expression); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'SqlDesign' } }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(mocks.expressionUpdate).not.toHaveBeenCalled(); + expect(mocks.staticUpdate).not.toHaveBeenCalled(); + expect(mocks.state.connections.connectionsMapping.action).toBeNull(); + expect(mocks.dispatch).toHaveBeenCalledWith({ type: 'openPanel', payload: { nodeId: 'action', panelMode: 'Operation' } }); + }); + + it('reports incomplete setup when applying the binding fails during metadata loading', async () => { + renderPanel(); + await expressionMode(); + await edit(expression); + mocks.dispatch.mockImplementationOnce(() => ({ unwrap: () => Promise.reject(new Error('Metadata load failed')) })); + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + + expect( + await screen.findByText('Connection expression setup did not finish. Check the design-time connection and try again.') + ).toBeInTheDocument(); + expect(mocks.expressionUpdate).toHaveBeenCalled(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled()); + expect(mocks.dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'openPanel' })); + }); + + it.each(['SqlDesign', 'sqldesign'])('applies the exact design-time key %s without replacing the expression', async (key) => { + renderPanel(); + await expressionMode(); + await edit(expression); + fireEvent.change(screen.getByRole('combobox'), { target: { value: key } }); + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + await waitFor(() => expect(mocks.expressionUpdate).toHaveBeenCalledWith({ nodeId: 'action', expression, designTimeReferenceKey: key })); + expect(mocks.staticUpdate).not.toHaveBeenCalled(); + }); + + it.each<[string, () => void]>([ + [ + 'Consumption', + () => { + mocks.state.workflow.workflowKind = undefined; + }, + ], + [ + 'trigger', + () => { + mocks.state.workflow.nodesMetadata.action.isTrigger = true; + }, + ], + [ + 'managed API', + () => { + mocks.state.operations.operationInfo.action.type = 'ApiConnection'; + }, + ], + ])('preserves imported expressions in unsupported %s context without auto-creating a connection', (_context, configure) => { + configure(); + mocks.state.connections.connectionsMapping.action = { kind: 'expression', expression }; + renderPanel(); + expect(screen.getByRole('textbox', { name: 'Connection expression' })).toHaveTextContent(expression); + expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); + expect(mocks.autoCreate).not.toHaveBeenCalled(); + expect(mocks.dispatch).not.toHaveBeenCalled(); + expect(mocks.expressionUpdate).not.toHaveBeenCalled(); + }); + + it.each(['readOnly', 'isMonitoringView'] as const)('respects %s without auto-creating or mutating connections', (mode) => { + mocks.state.designerOptions[mode] = true; + mocks.state.connections.connectionsMapping.action = { kind: 'expression', expression }; + renderPanel(); + expect(screen.getByRole('radio', { name: 'Existing connection' })).toBeDisabled(); + expect(screen.getByRole('textbox', { name: 'Connection expression' })).toHaveAttribute('contenteditable', 'false'); + expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); + expect(mocks.autoCreate).not.toHaveBeenCalled(); + expect(mocks.staticUpdate).not.toHaveBeenCalled(); + expect(mocks.expressionUpdate).not.toHaveBeenCalled(); + expect(mocks.dispatch).not.toHaveBeenCalled(); + }); + + it.each(['readOnly', 'isMonitoringView'] as const)( + 'blocks static selection and auto-create in %s even without an imported expression', + (mode) => { + mocks.state.designerOptions[mode] = true; + renderPanel(); + expect(screen.queryByRole('radio', { name: 'Use expression' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Select SqlDesign' })); + expect(mocks.staticUpdate).not.toHaveBeenCalled(); + expect(mocks.setupConnection).not.toHaveBeenCalled(); + expect(mocks.autoCreate).not.toHaveBeenCalled(); + expect(mocks.dispatch).not.toHaveBeenCalled(); + } + ); + + it('can intentionally replace an expression with an existing concrete connection', () => { + mocks.state.connections.connectionsMapping.action = { kind: 'expression', expression, designTimeReferenceKey: 'SqlDesign' }; + renderPanel(); + fireEvent.click(screen.getByRole('radio', { name: 'Existing connection' })); + fireEvent.click(screen.getByRole('button', { name: 'Select SqlDesign' })); + expect(mocks.staticUpdate).toHaveBeenCalled(); + expect(mocks.expressionUpdate).not.toHaveBeenCalled(); + }); + + it('connects the real string editor to the normal dynamic token conversion without changing workflow state', async () => { + renderPanel(); + await expressionMode(); + await edit(''); + act(() => screen.getByRole('textbox', { name: 'Connection expression' }).focus()); + const tokenPickerButton = await waitFor(() => { + const button = document.querySelector( + '[data-automation-id="msla-token-picker-entrypoint-button-dynamic-content"]' + ); + expect(button).not.toBeNull(); + return button!; + }); + fireEvent.click(tokenPickerButton); + expect(screen.getByRole('button', { name: 'Output' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Array item connection' })).not.toBeInTheDocument(); + expect(mocks.pickerProps.filteredTokenGroup.flatMap((group: any) => group.tokens)).not.toEqual( + expect.arrayContaining([expect.objectContaining({ title: 'Array item connection' })]) + ); + fireEvent.click(screen.getByRole('button', { name: 'ConnectionName' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled()); + expect(createValueSegmentFromToken).toHaveBeenCalledWith( + 'action', + 'connectionName', + expect.anything(), + false, + false, + mocks.state, + mocks.dispatch + ); + expect(mocks.pickerProps.tokenGroup[1].tokens[0].title).toBe('Output'); + expect(mocks.dispatch).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + await waitFor(() => + expect(mocks.expressionUpdate).toHaveBeenCalledWith({ + nodeId: 'action', + expression: "@parameters('ConnectionName')", + designTimeReferenceKey: undefined, + }) + ); + }); +}); + +describe('runtime connection display', () => { + it.each([ + ['malformed imported expression', '@if(', undefined, false], + ['selected design-time connection error', '@triggerBody()', 'SqlDesign', true], + ] as const)('shows an error for a %s without hiding the runtime expression', (_case, value, designTimeReferenceKey, hasError) => { + mocks.state.connections.connectionsMapping.action = { kind: 'expression', expression: value, designTimeReferenceKey }; + render( + + + + ); + + expect(screen.getByText('Connection selected at runtime')).toBeInTheDocument(); + expect(screen.getByText(value)).toBeInTheDocument(); + expect(screen.getByText('Invalid connection')).toBeInTheDocument(); + expect(mocks.dispatch).not.toHaveBeenCalled(); + }); + + it('lists runtime-only connections instead of empty state and opens the original action ID', () => { + mocks.state.connections.connectionReferences = {}; + mocks.state.connections.connectionsMapping = { + action: { kind: 'expression', expression }, + Unrenamed: { kind: 'expression', expression: "@parameters('ConnectionName')" }, + }; + mocks.state.workflow.idReplacements = { action: 'Renamed action' }; + render( + + + + ); + + expect(screen.getByText('Connections selected at runtime')).toBeInTheDocument(); + expect(screen.getByText(expression)).toBeInTheDocument(); + expect(screen.getByText("@parameters('ConnectionName')")).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Unrenamed' })).toBeInTheDocument(); + expect(screen.queryByRole('region', { name: 'No connections found' })).not.toBeInTheDocument(); + expect(screen.queryByText('Concrete connections')).not.toBeInTheDocument(); + expect(mocks.connectorQuery).not.toHaveBeenCalled(); + expect(mocks.dispatch).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Renamed action' })); + expect(mocks.dispatch).toHaveBeenCalledExactlyOnceWith({ type: 'openPanel', payload: { nodeId: 'action', panelMode: 'Connection' } }); + }); + + it('retains the empty state when there are no static or runtime connection mappings', () => { + mocks.state.connections.connectionsMapping = {}; + mocks.state.connections.connectionReferences = {}; + render( + + + + ); + + expect(screen.getByRole('region', { name: 'No connections found' })).toBeInTheDocument(); + expect(screen.queryByText('Connections selected at runtime')).not.toBeInTheDocument(); + }); + + it('shows the imported expression instead of invalid/missing/loading state without opening the panel', () => { + mocks.state.connections.connectionsMapping.action = { kind: 'expression', expression }; + render( + + + + ); + expect(screen.getByText('Connection selected at runtime')).toBeInTheDocument(); + expect(screen.getByText(expression)).toBeInTheDocument(); + expect(screen.queryByText('Invalid connection')).not.toBeInTheDocument(); + expect(mocks.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/designer-v2/src/lib/ui/panel/connectionsPanel/selectConnection/connectionExpression.tsx b/libs/designer-v2/src/lib/ui/panel/connectionsPanel/selectConnection/connectionExpression.tsx new file mode 100644 index 00000000000..c6d1114febe --- /dev/null +++ b/libs/designer-v2/src/lib/ui/panel/connectionsPanel/selectConnection/connectionExpression.tsx @@ -0,0 +1,290 @@ +import { useMonitoringView, useReadOnly } from '../../../../core/state/designerOptions/designerOptionsSelectors'; +import type { AppDispatch, RootState } from '../../../../core/store'; +import { isTriggerNode } from '../../../../core/utils/graph'; +import { isConnectionExpressionValid } from '../../../../core/utils/connectors/connectionExpression'; +import { parameterValueToStringWithoutCasting } from '../../../../core/utils/parameters/helper'; +import { createValueSegmentFromToken, getExpressionTokenSections, getOutputTokenSections } from '../../../../core/utils/tokens'; +import { isExpressionConnectionMapping, type ConnectionMapping, type ConnectionReferences } from '../../../../common/models/workflow'; +import { Button, Field, Radio, RadioGroup, Select, Text, makeStyles, tokens, useId } from '@fluentui/react-components'; +import { StringEditor, TokenPicker, createLiteralValueSegment, type ParameterInfo, type ValueSegment } from '@microsoft/designer-ui'; +import { equals, getRecordEntry, isServiceProviderOperation } from '@microsoft/logic-apps-shared'; +import { useMemo, useState, type ReactNode } from 'react'; +import { useIntl } from 'react-intl'; +import { useDispatch, useSelector } from 'react-redux'; + +const useStyles = makeStyles({ + root: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM }, + form: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalL, padding: tokens.spacingHorizontalM }, + actions: { display: 'flex', gap: tokens.spacingHorizontalS }, +}); + +export const useConnectionExpressionEnabled = (nodeIds: string[]) => + useSelector( + (state: RootState) => + !state.designerOptions.readOnly && + !state.designerOptions.isMonitoringView && + !!state.workflow.workflowKind && + nodeIds.length === 1 && + nodeIds.every( + (nodeId) => + isServiceProviderOperation(getRecordEntry(state.operations.operationInfo, nodeId)?.type) && + !isTriggerNode(nodeId, state.workflow.nodesMetadata) + ) + ); + +interface ConnectionExpressionSelectionProps { + nodeId: string; + mapping: ConnectionMapping[string] | undefined; + connectorId: string; + references: ConnectionReferences; + enabled: boolean; + existingConnections: ReactNode; + onApply: (expression: string, designTimeReferenceKey?: string) => Promise; + onCancel: () => void; +} + +export const ConnectionExpressionSelection = ({ + nodeId, + mapping, + connectorId, + references, + enabled, + existingConnections, + onApply, + onCancel, +}: ConnectionExpressionSelectionProps) => { + const intl = useIntl(); + const styles = useStyles(); + const isReadOnly = useReadOnly(); + const isMonitoringView = useMonitoringView(); + const readOnly = isReadOnly || isMonitoringView; + const expressionMapping = isExpressionConnectionMapping(mapping) ? mapping : undefined; + const [mode, setMode] = useState(expressionMapping ? 'expression' : 'existing'); + const [value, setValue] = useState(() => [createLiteralValueSegment(expressionMapping?.expression ?? '')]); + const [designTimeReferenceKey, setDesignTimeReferenceKey] = useState(expressionMapping?.designTimeReferenceKey ?? ''); + const [isApplying, setIsApplying] = useState(false); + const [applyFailed, setApplyFailed] = useState(false); + const expression = parameterValueToStringWithoutCasting(value); + const valid = isConnectionExpressionValid(expression); + const expressionReadOnly = !!readOnly || !enabled || isApplying; + const referenceKeys = Object.keys(references).filter((key) => equals(references[key].api.id, connectorId, true)); + const modeLabelId = useId('connection-mode'); + const expressionLabel = intl.formatMessage({ + defaultMessage: 'Connection expression', + id: 'F6vqih', + description: 'Label for the runtime connection expression editor', + }); + const error = intl.formatMessage({ + defaultMessage: + "Enter a valid workflow expression, for example @parameters('connectionName'). Escaped text starting with @@ is not an expression.", + id: 'Ndfl+w', + description: 'Validation error for a runtime connection expression', + }); + + const apply = async () => { + if (expressionReadOnly || !valid) { + return; + } + setIsApplying(true); + setApplyFailed(false); + try { + await onApply(expression, referenceKeys.includes(designTimeReferenceKey) ? designTimeReferenceKey : undefined); + } catch { + setApplyFailed(true); + } finally { + setIsApplying(false); + } + }; + + return ( +
+ + {intl.formatMessage({ + defaultMessage: 'Connection mode', + id: 'Aks6v3', + description: 'Accessible label for choosing the connection mode', + })} + + setMode(data.value)} + disabled={!!readOnly || isApplying} + > + + + + {mode === 'existing' ? ( + existingConnections + ) : ( +
+ + {intl.formatMessage({ + defaultMessage: 'The expression selects a connection at runtime. No connection is created by the designer.', + id: 'w5FZqt', + description: 'Explains runtime connection expression behavior', + })} + + + { + setValue(newValue); + setApplyFailed(false); + }} + /> + + + + + {enabled ? null : ( + + {intl.formatMessage({ + defaultMessage: 'This host does not enable editing connection expressions. The existing expression is preserved.', + id: '7bEIoW', + description: 'Imported expression is preserved when expression authoring is disabled', + })} + + )} + {applyFailed ? ( + + {intl.formatMessage({ + defaultMessage: 'Connection expression setup did not finish. Check the design-time connection and try again.', + id: 'Kx/APX', + description: 'Error applying the connection expression', + })} + + ) : null} +
+ + +
+
+ )} +
+ ); +}; + +interface ConnectionExpressionEditorProps { + nodeId: string; + value: ValueSegment[]; + label: string; + readOnly: boolean; + onChange: (value: ValueSegment[]) => void; +} + +export const ConnectionExpressionEditor = ({ nodeId, value, label, readOnly, onChange }: ConnectionExpressionEditorProps) => { + const dispatch = useDispatch(); + const state = useSelector((state: RootState) => state); + const nodeType = getRecordEntry(state.operations.operationInfo, nodeId)?.type ?? ''; + const tokenGroup = useMemo( + () => + getOutputTokenSections(nodeId, nodeType, state.tokens, state.workflowParameters, state.workflow, state.workflow.idReplacements).map( + (group) => ({ + ...group, + // Array-property tokens require implicit loop binding. Use explicit items(...) expressions instead. + tokens: group.tokens.filter((token) => !token.outputInfo.arrayDetails), + }) + ), + [nodeId, nodeType, state.tokens, state.workflowParameters, state.workflow] + ); + const expressionGroup = useMemo(() => getExpressionTokenSections(), []); + const labelId = useId('connection-expression'); + const parameter: ParameterInfo = { + id: 'connectionName', + parameterKey: 'connectionName', + parameterName: 'connectionName', + label, + type: 'string', + required: true, + info: {}, + value, + }; + return ( + <> + + onChange(value)} + getTokenPicker={(editorId, pickerLabelId, initialMode, _type, tokenClickedCallback) => ( + + // A draft connection expression must not insert loops or change workflow metadata. + createValueSegmentFromToken(nodeId, parameter.id, token, false, false, state, dispatch) + } + /> + )} + /> + + ); +}; diff --git a/libs/designer-v2/src/lib/ui/panel/connectionsPanel/selectConnection/selectConnection.tsx b/libs/designer-v2/src/lib/ui/panel/connectionsPanel/selectConnection/selectConnection.tsx index 68039d81c1f..0c967fcbb43 100644 --- a/libs/designer-v2/src/lib/ui/panel/connectionsPanel/selectConnection/selectConnection.tsx +++ b/libs/designer-v2/src/lib/ui/panel/connectionsPanel/selectConnection/selectConnection.tsx @@ -1,14 +1,23 @@ import { useIsA2AWorkflow } from '../../../../core/state/designerView/designerViewSelectors'; import { useOperationInfo, type AppDispatch } from '../../../../core'; -import { autoCreateConnectionIfPossible, updateNodeConnection } from '../../../../core/actions/bjsworkflow/connections'; +import { + autoCreateConnectionIfPossible, + updateNodeConnection, + updateNodeConnectionExpression, +} from '../../../../core/actions/bjsworkflow/connections'; import { useConnectionsForConnector } from '../../../../core/queries/connections'; import { useConnectionRefs, useConnectionRefsByConnectorId, useConnectorByNodeId, useNodeConnectionId, + useNodeConnectionMapping, } from '../../../../core/state/connection/connectionSelector'; -import { useIsXrmConnectionReferenceMode } from '../../../../core/state/designerOptions/designerOptionsSelectors'; +import { + useIsXrmConnectionReferenceMode, + useMonitoringView, + useReadOnly, +} from '../../../../core/state/designerOptions/designerOptionsSelectors'; import { useConnectionPanelSelectedNodeIds, useOperationPanelSelectedNodeId, @@ -34,12 +43,21 @@ import { useIntl } from 'react-intl'; import { useDispatch } from 'react-redux'; import { AgentUtils, isDynamicConnection } from '../../../../common/utilities/Utils'; import { useIsAgentSubGraph } from '../../../../common/hooks/agent'; +import { isExpressionConnectionMapping } from '../../../../common/models/workflow'; +import { ConnectionExpressionSelection, useConnectionExpressionEnabled } from './connectionExpression'; export const SelectConnectionWrapper = () => { const dispatch = useDispatch(); const intl = useIntl(); const selectedNodeIds = useConnectionPanelSelectedNodeIds(); + const isReadOnly = useReadOnly(); + const isMonitoringView = useMonitoringView(); + const readOnly = isReadOnly || isMonitoringView; + const expressionEnabled = useConnectionExpressionEnabled(selectedNodeIds); + const mapping = useNodeConnectionMapping(selectedNodeIds?.[0]); + const runtimeConnection = isExpressionConnectionMapping(mapping); + const showExpressionSelection = expressionEnabled || runtimeConnection; const isA2A = useIsA2AWorkflow(); const nodeId: string = useOperationPanelSelectedNodeId(); const isAgentSubgraph = useIsAgentSubGraph(nodeId); @@ -110,7 +128,7 @@ export const SelectConnectionWrapper = () => { const saveSelectionCallback = useCallback( (connection?: Connection) => { - if (!connection) { + if (!connection || readOnly) { return; } for (const nodeId of selectedNodeIds) { @@ -125,10 +143,13 @@ export const SelectConnectionWrapper = () => { } closeConnectionsFlow(); }, - [dispatch, selectedNodeIds, connector, closeConnectionsFlow] + [dispatch, selectedNodeIds, connector, closeConnectionsFlow, readOnly] ); const createConnectionCallback = useCallback(() => { + if (readOnly) { + return; + } setIsInlineCreatingConnection(true); autoCreateConnectionIfPossible({ connector: connector as Connector, @@ -142,13 +163,21 @@ export const SelectConnectionWrapper = () => { dispatch(setIsCreatingConnection(true)); }, }); - }, [closeConnectionsFlow, connector, dispatch, operationInfo, references, saveSelectionCallback]); + }, [closeConnectionsFlow, connector, dispatch, operationInfo, references, saveSelectionCallback, readOnly]); useEffect(() => { - if (!connectionQuery.isLoading && !connectionQuery.isError && connections.length === 0) { + if (!readOnly && !showExpressionSelection && !connectionQuery.isLoading && !connectionQuery.isError && connections.length === 0) { createConnectionCallback(); } - }, [connectionQuery.isError, connectionQuery.isLoading, connections, connector, createConnectionCallback]); + }, [ + connectionQuery.isError, + connectionQuery.isLoading, + connections, + connector, + createConnectionCallback, + readOnly, + showExpressionSelection, + ]); const actionBar = useMemo(() => { return ( @@ -176,7 +205,7 @@ export const SelectConnectionWrapper = () => { description: 'Button text for adding a new connection', }); - if (connectionQuery.isLoading) { + if (connectionQuery.isLoading && !showExpressionSelection) { return (
@@ -184,23 +213,52 @@ export const SelectConnectionWrapper = () => { ); } - return ( + const existingConnections = ( ); + + if (!showExpressionSelection) { + return existingConnections; + } + + return ( + <> + {actionBar} + : existingConnections} + onApply={async (expression, designTimeReferenceKey) => { + if (readOnly || !expressionEnabled || selectedNodeIds.length !== 1) { + return; + } + for (const nodeId of selectedNodeIds) { + await dispatch(updateNodeConnectionExpression({ nodeId, expression, designTimeReferenceKey })).unwrap(); + } + closeConnectionsFlow(); + }} + onCancel={closeConnectionsFlow} + /> + + ); }; export const SelectConnection = ({ diff --git a/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/tabs/parametersTab/connectionDisplay.tsx b/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/tabs/parametersTab/connectionDisplay.tsx index ffd8e560b8f..36fe3ccdfab 100644 --- a/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/tabs/parametersTab/connectionDisplay.tsx +++ b/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/tabs/parametersTab/connectionDisplay.tsx @@ -1,8 +1,10 @@ import { openPanel } from '../../../../../core'; -import { useIsOperationMissingConnection } from '../../../../../core/state/connection/connectionSelector'; +import { useIsOperationMissingConnection, useNodeConnectionMapping } from '../../../../../core/state/connection/connectionSelector'; +import { isExpressionConnectionMapping } from '../../../../../common/models/workflow'; +import { isConnectionExpressionValid } from '../../../../../core/utils/connectors/connectionExpression'; import { useIsXrmConnectionReferenceMode } from '../../../../../core/state/designerOptions/designerOptionsSelectors'; import { useIsConnectionRequired, useOperationInfo } from '../../../../../core/state/selectors/actionMetadataSelector'; -import { Badge, Button, InfoLabel, Spinner } from '@fluentui/react-components'; +import { Badge, Button, InfoLabel, Spinner, Text, makeStyles, tokens } from '@fluentui/react-components'; import { ErrorCircle16Filled, LinkMultiple16Regular } from '@fluentui/react-icons'; import { Label } from '@microsoft/designer-ui'; import { useCallback, useEffect, useMemo } from 'react'; @@ -18,6 +20,16 @@ interface ConnectionDisplayProps { hasError: boolean; } +const useStyles = makeStyles({ + expression: { + display: 'block', + overflowWrap: 'anywhere', + whiteSpace: 'pre-wrap', + fontFamily: tokens.fontFamilyMonospace, + paddingTop: tokens.spacingVerticalXS, + }, +}); + export const ConnectionDisplay = (props: ConnectionDisplayProps) => { const { connectionName, nodeId, hasError, isLoading = false, readOnly, readOnlyReason } = props; @@ -26,6 +38,15 @@ export const ConnectionDisplay = (props: ConnectionDisplayProps) => { const isXrmConnectionReferenceMode = useIsXrmConnectionReferenceMode(); const isOperationMissingConnection = useIsOperationMissingConnection(nodeId); + const mapping = useNodeConnectionMapping(nodeId); + const runtimeConnection = isExpressionConnectionMapping(mapping); + const invalidExpression = runtimeConnection && !isConnectionExpressionValid(mapping.expression); + const styles = useStyles(); + const runtimeConnectionText = intl.formatMessage({ + defaultMessage: 'Connection selected at runtime', + id: 'elDTa6', + description: 'Status for a connection selected at runtime by an expression', + }); const openChangeConnectionCallback = useCallback(() => { dispatch(openPanel({ nodeId, panelMode: 'Connection' })); @@ -35,10 +56,10 @@ export const ConnectionDisplay = (props: ConnectionDisplayProps) => { const requiresConnection = useIsConnectionRequired(operationInfo); useEffect(() => { - if (requiresConnection && isOperationMissingConnection) { + if (requiresConnection && isOperationMissingConnection && !runtimeConnection && !readOnly) { openChangeConnectionCallback(); } - }, [isOperationMissingConnection, openChangeConnectionCallback, requiresConnection]); + }, [isOperationMissingConnection, openChangeConnectionCallback, requiresConnection, runtimeConnection, readOnly]); const connectionDisplayTextWithName = intl.formatMessage( { @@ -82,11 +103,11 @@ export const ConnectionDisplay = (props: ConnectionDisplayProps) => { }); const connectionLabel = useMemo( - () => (connectionName ? connectionDisplayTextWithName : connectionDisplayTextWithoutName), - [connectionName, connectionDisplayTextWithName, connectionDisplayTextWithoutName] + () => (runtimeConnection ? runtimeConnectionText : connectionName ? connectionDisplayTextWithName : connectionDisplayTextWithoutName), + [runtimeConnection, runtimeConnectionText, connectionName, connectionDisplayTextWithName, connectionDisplayTextWithoutName] ); - if (isLoading) { + if (isLoading && !runtimeConnection) { return (
@@ -94,7 +115,7 @@ export const ConnectionDisplay = (props: ConnectionDisplayProps) => { ); } - const labelText = connectionName ? connectionDisplayTextWithName : connectionDisplayTextWithoutName; + const labelText = connectionLabel; return (
@@ -123,7 +144,7 @@ export const ConnectionDisplay = (props: ConnectionDisplayProps) => { )}
- {hasError ? ( + {invalidExpression || (hasError && (!runtimeConnection || mapping.designTimeReferenceKey)) ? (
}> {connectionErrorText} @@ -131,6 +152,7 @@ export const ConnectionDisplay = (props: ConnectionDisplayProps) => {
) : null}
+ {runtimeConnection ? {mapping.expression} : null}
); }; diff --git a/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/tabs/parametersTab/identityselector.tsx b/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/tabs/parametersTab/identityselector.tsx index 9dd46354751..bd142045096 100644 --- a/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/tabs/parametersTab/identityselector.tsx +++ b/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/tabs/parametersTab/identityselector.tsx @@ -46,7 +46,7 @@ export const IdentitySelector = (props: IdentitySelectorProps) => { const options = getIdentityDropdownOptions(identity, intl); const selectedIdentity = useSelector((state: RootState) => { - const { connectionProperties } = getConnectionReference(state.connections, nodeId); + const connectionProperties = getConnectionReference(state.connections, nodeId)?.connectionProperties; return equals(connectionProperties?.authentication?.type, 'ManagedServiceIdentity') ? (connectionProperties?.authentication?.identity ?? constants.SYSTEM_ASSIGNED_MANAGED_IDENTITY) : undefined;