From 1539ae5daa1156162e0fa4809f0946595cab3281 Mon Sep 17 00:00:00 2001 From: Dawid Poliszak Date: Tue, 19 Nov 2024 14:39:28 +0100 Subject: [PATCH 1/2] [Nu-1858] separate properties and node details (#7129) * NU-1858 separate properties and node details --- designer/client/cypress/e2e/description.cy.ts | 4 +- designer/client/src/actions/actionTypes.ts | 3 +- .../actions/nk/calculateProcessAfterChange.ts | 34 +---- designer/client/src/actions/nk/editNode.ts | 4 - .../client/src/actions/nk/editProperties.ts | 58 +++++++++ designer/client/src/actions/nk/index.ts | 1 + designer/client/src/actions/nk/node.ts | 3 +- designer/client/src/actions/nk/nodeDetails.ts | 6 +- designer/client/src/actions/reduxTypes.ts | 5 +- .../src/assets/json/nodeAttributes.json | 3 - .../src/components/ComponentDragPreview.tsx | 4 + .../src/components/ComponentPreview.tsx | 2 +- .../src/components/graph/EspNode/element.ts | 2 +- .../client/src/components/graph/Graph.tsx | 3 +- .../client/src/components/graph/NodeUtils.ts | 44 +------ .../graph/SelectionContextProvider.tsx | 6 +- .../graph/node-modal/DescriptionField.tsx | 4 +- .../node-modal/DescriptionOnlyContent.tsx | 16 +-- .../components/graph/node-modal/IdField.tsx | 6 +- .../node-modal/NodeAdditionalInfoBox.tsx | 45 +++---- .../graph/node-modal/NodeDetailsContent.tsx | 3 +- .../components/graph/node-modal/NodeField.tsx | 4 +- .../node-modal/NodeTypeDetailsContent.tsx | 16 +-- .../graph/node-modal/ParametersUtils.ts | 3 +- .../editors/expression/JsonEditor.tsx | 1 + .../node-modal/node/DescriptionDialog.tsx | 66 ++++++---- .../node-modal/node/NodeGroupContent.tsx | 4 +- .../nodeDetails/NodeDetailsModalHeader.tsx | 13 +- .../graph/node-modal/properties.tsx | 87 ------------- .../modals/CompareVersionsDialog.tsx | 3 +- .../components/modals/PropertiesDialog.tsx | 120 ++++++++++++++++++ .../src/components/properties/NameField.tsx | 38 ++++++ .../components/properties/PropertiesForm.tsx | 72 +++++++++++ .../ScenarioProperty.tsx | 8 +- .../client/src/components/properties/index.ts | 1 + .../src/components/tips/error/ErrorTips.tsx | 6 +- .../src/components/tips/error/Errors.tsx | 4 +- .../components/tips/error/NodeErrorLink.tsx | 27 +--- .../tips/error/NodeErrorsLinkSection.tsx | 8 +- .../tips/error/ScenarioPropertiesSection.tsx | 20 +++ .../src/components/tips/error/styled.ts | 18 +++ .../src/components/toolbars/creator/Icon.tsx | 4 +- .../buttons/PropertiesButton.tsx | 23 ++-- .../src/components/toolbars/search/utils.ts | 2 +- .../src/containers/ScenarioDescription.tsx | 59 +++++++-- .../hooks/useModalDetailsIfNeeded.tsx | 6 +- .../src/containers/theme/darkModePalette.ts | 7 +- .../src/containers/theme/lightModePalette.ts | 7 +- .../client/src/containers/theme/nuTheme.tsx | 6 +- .../client/src/containers/theme/styles.ts | 2 +- designer/client/src/reducers/graph/reducer.ts | 10 ++ .../src/reducers/graph/utils.fixtures.ts | 2 +- designer/client/src/reducers/graph/utils.ts | 2 +- designer/client/src/types/node.ts | 10 +- designer/client/src/types/scenarioGraph.ts | 2 +- .../src/windowManager/ContentGetter.tsx | 6 + .../client/src/windowManager/WindowKind.tsx | 1 + .../client/src/windowManager/useWindows.ts | 25 +--- designer/client/test/CommentContent-test.tsx | 1 - .../client/test/CountsRangesButtons-test.tsx | 2 +- designer/client/test/PlainStyleLink-test.tsx | 3 - .../FixedValuesGroup-test.tsx | 2 +- .../FrgamentInputDefinition/Item-test.tsx | 2 +- .../test/Process/ProcessStateIcon-test.tsx | 2 +- designer/client/test/SpelQuotesUtils-test.tsx | 2 +- .../test/useAnonymousStatistics.test.tsx | 2 +- 66 files changed, 573 insertions(+), 392 deletions(-) create mode 100644 designer/client/src/actions/nk/editProperties.ts delete mode 100644 designer/client/src/components/graph/node-modal/properties.tsx create mode 100644 designer/client/src/components/modals/PropertiesDialog.tsx create mode 100644 designer/client/src/components/properties/NameField.tsx create mode 100644 designer/client/src/components/properties/PropertiesForm.tsx rename designer/client/src/components/{graph/node-modal => properties}/ScenarioProperty.tsx (86%) create mode 100644 designer/client/src/components/properties/index.ts create mode 100644 designer/client/src/components/tips/error/ScenarioPropertiesSection.tsx create mode 100644 designer/client/src/components/tips/error/styled.ts diff --git a/designer/client/cypress/e2e/description.cy.ts b/designer/client/cypress/e2e/description.cy.ts index ff4c3632fa0..8c597ca1550 100644 --- a/designer/client/cypress/e2e/description.cy.ts +++ b/designer/client/cypress/e2e/description.cy.ts @@ -22,9 +22,7 @@ describe("Description", () => { it("should display markdown", () => { cy.get(`[title="toggle description view"]`).should("not.exist"); - cy.contains(/^properties$/i) - .should("be.enabled") - .dblclick(); + cy.contains(/^properties$/i).click(); cy.get("[data-testid=window]").should("be.visible").as("window"); cy.get("[data-testid=window]").contains("Description").next().find(".ace_editor").should("be.visible").click("center") diff --git a/designer/client/src/actions/actionTypes.ts b/designer/client/src/actions/actionTypes.ts index 98eb8dc62f9..36388502dd2 100644 --- a/designer/client/src/actions/actionTypes.ts +++ b/designer/client/src/actions/actionTypes.ts @@ -41,4 +41,5 @@ export type ActionTypes = | "PROCESS_VERSIONS_LOADED" | "UPDATE_BACKEND_NOTIFICATIONS" | "MARK_BACKEND_NOTIFICATION_READ" - | "ARCHIVED"; + | "ARCHIVED" + | "EDIT_PROPERTIES"; diff --git a/designer/client/src/actions/nk/calculateProcessAfterChange.ts b/designer/client/src/actions/nk/calculateProcessAfterChange.ts index b7bc80c9b43..f47595679b0 100644 --- a/designer/client/src/actions/nk/calculateProcessAfterChange.ts +++ b/designer/client/src/actions/nk/calculateProcessAfterChange.ts @@ -1,44 +1,16 @@ -import NodeUtils from "../../components/graph/NodeUtils"; -import { fetchProcessDefinition } from "./processDefinitionData"; import { getProcessDefinitionData } from "../../reducers/selectors/settings"; import { mapProcessWithNewNode, replaceNodeOutputEdges } from "../../components/graph/utils/graphUtils"; -import { alignFragmentWithSchema } from "../../components/graph/utils/fragmentSchemaAligner"; -import { Edge, NodeType, ScenarioGraph, ProcessDefinitionData, ScenarioGraphWithName } from "../../types"; +import { Edge, NodeType, ScenarioGraphWithName } from "../../types"; import { ThunkAction } from "../reduxTypes"; import { Scenario } from "../../components/Process/types"; -function alignFragmentsNodeWithSchema(scenarioGraph: ScenarioGraph, processDefinitionData: ProcessDefinitionData): ScenarioGraph { - return { - ...scenarioGraph, - nodes: scenarioGraph.nodes.map((node) => { - return node.type === "FragmentInput" ? alignFragmentWithSchema(processDefinitionData, node) : node; - }), - }; -} - export function calculateProcessAfterChange( scenario: Scenario, before: NodeType, after: NodeType, outputEdges: Edge[], ): ThunkAction> { - return async (dispatch, getState) => { - if (NodeUtils.nodeIsProperties(after)) { - const processDefinitionData = await dispatch(fetchProcessDefinition(scenario.processingType, scenario.isFragment)); - const processWithNewFragmentSchema = alignFragmentsNodeWithSchema(scenario.scenarioGraph, processDefinitionData); - // TODO: We shouldn't keep scenario name in properties.id - it is a top-level scenario property - if (after.id !== before.id) { - dispatch({ type: "PROCESS_RENAME", name: after.id }); - } - - const { id, ...properties } = after; - - return { - processName: after.id, - scenarioGraph: { ...processWithNewFragmentSchema, properties }, - }; - } - + return async (_, getState) => { let changedProcess = scenario.scenarioGraph; if (outputEdges) { const processDefinitionData = getProcessDefinitionData(getState()); @@ -54,7 +26,7 @@ export function calculateProcessAfterChange( } return { - processName: scenario.scenarioGraph.properties.id || scenario.name, + processName: scenario.name, scenarioGraph: mapProcessWithNewNode(changedProcess, before, after), }; }; diff --git a/designer/client/src/actions/nk/editNode.ts b/designer/client/src/actions/nk/editNode.ts index b765adbaf42..1886f8e48c1 100644 --- a/designer/client/src/actions/nk/editNode.ts +++ b/designer/client/src/actions/nk/editNode.ts @@ -12,10 +12,6 @@ export type EditNodeAction = { validationResult: ValidationResult; scenarioGraphAfterChange: ScenarioGraph; }; -export type RenameProcessAction = { - type: "PROCESS_RENAME"; - name: string; -}; export type EditScenarioLabels = { type: "EDIT_LABELS"; diff --git a/designer/client/src/actions/nk/editProperties.ts b/designer/client/src/actions/nk/editProperties.ts new file mode 100644 index 00000000000..31bc51193d0 --- /dev/null +++ b/designer/client/src/actions/nk/editProperties.ts @@ -0,0 +1,58 @@ +import { ProcessDefinitionData, PropertiesType, ScenarioGraph, ScenarioGraphWithName, ValidationResult } from "../../types"; +import { alignFragmentWithSchema } from "../../components/graph/utils/fragmentSchemaAligner"; +import { fetchProcessDefinition } from "./processDefinitionData"; +import { Scenario } from "../../components/Process/types"; +import HttpService from "../../http/HttpService"; +import { ThunkAction } from "../reduxTypes"; + +type EditPropertiesAction = { + type: "EDIT_PROPERTIES"; + validationResult: ValidationResult; + scenarioGraphAfterChange: ScenarioGraph; +}; + +type RenameProcessAction = { + type: "PROCESS_RENAME"; + name: string; +}; + +export type PropertiesActions = EditPropertiesAction | RenameProcessAction; + +// TODO: We synchronize fragment changes with a scenario in case of properties changes. We need to find a better way to hande it +function alignFragmentsNodeWithSchema(scenarioGraph: ScenarioGraph, processDefinitionData: ProcessDefinitionData): ScenarioGraph { + return { + ...scenarioGraph, + nodes: scenarioGraph.nodes.map((node) => { + return node.type === "FragmentInput" ? alignFragmentWithSchema(processDefinitionData, node) : node; + }), + }; +} + +const calculateProperties = (scenario: Scenario, changedProperties: PropertiesType): ThunkAction> => { + return async (dispatch) => { + const processDefinitionData = await dispatch(fetchProcessDefinition(scenario.processingType, scenario.isFragment)); + const processWithNewFragmentSchema = alignFragmentsNodeWithSchema(scenario.scenarioGraph, processDefinitionData); + + if (scenario.name !== changedProperties.name) { + dispatch({ type: "PROCESS_RENAME", name: changedProperties.name }); + } + + return { + processName: changedProperties.name, + scenarioGraph: { ...processWithNewFragmentSchema, properties: changedProperties }, + }; + }; +}; + +export function editProperties(scenario: Scenario, changedProperties: PropertiesType): ThunkAction { + return async (dispatch) => { + const { processName, scenarioGraph } = await dispatch(calculateProperties(scenario, changedProperties)); + const response = await HttpService.validateProcess(scenario.name, processName, scenarioGraph); + + dispatch({ + type: "EDIT_PROPERTIES", + validationResult: response.data, + scenarioGraphAfterChange: scenarioGraph, + }); + }; +} diff --git a/designer/client/src/actions/nk/index.ts b/designer/client/src/actions/nk/index.ts index a4d340942a7..178928c7709 100644 --- a/designer/client/src/actions/nk/index.ts +++ b/designer/client/src/actions/nk/index.ts @@ -12,3 +12,4 @@ export * from "./ui/layout"; export * from "./zoom"; export * from "./nodeDetails"; export * from "./loadProcessToolbarsConfiguration"; +export * from "./editProperties"; diff --git a/designer/client/src/actions/nk/node.ts b/designer/client/src/actions/nk/node.ts index d1f80cb4435..a10ac7f5f23 100644 --- a/designer/client/src/actions/nk/node.ts +++ b/designer/client/src/actions/nk/node.ts @@ -1,7 +1,7 @@ import { Edge, EdgeType, NodeId, NodeType, ProcessDefinitionData, ValidationResult } from "../../types"; import { ThunkAction } from "../reduxTypes"; import { layoutChanged, Position } from "./ui/layout"; -import { EditNodeAction, EditScenarioLabels, RenameProcessAction } from "./editNode"; +import { EditNodeAction, EditScenarioLabels } from "./editNode"; import { getProcessDefinitionData } from "../../reducers/selectors/settings"; import { batchGroupBy } from "../../reducers/graph/batchGroupBy"; import NodeUtils from "../../components/graph/NodeUtils"; @@ -154,5 +154,4 @@ export type NodeActions = | NodesWithEdgesAddedAction | ValidationResultAction | EditNodeAction - | RenameProcessAction | EditScenarioLabels; diff --git a/designer/client/src/actions/nk/nodeDetails.ts b/designer/client/src/actions/nk/nodeDetails.ts index 1d39f6fda79..7fba33fb713 100644 --- a/designer/client/src/actions/nk/nodeDetails.ts +++ b/designer/client/src/actions/nk/nodeDetails.ts @@ -57,11 +57,7 @@ const validate = debounce( validationRequestData: ValidationRequest, callback: (nodeId: NodeId, data?: ValidationData | void) => void, ) => { - const validate = (node: NodeType) => - NodeUtils.nodeIsProperties(node) - ? //NOTE: we don't validationRequestData contains processProperties, but they are refreshed only on modal open - HttpService.validateProperties(processName, { additionalFields: node.additionalFields, name: node.id }) - : HttpService.validateNode(processName, { ...validationRequestData, nodeData: node }); + const validate = (node: NodeType) => HttpService.validateNode(processName, { ...validationRequestData, nodeData: node }); const nodeId = validationRequestData.nodeData.id; const nodeWithChangedName = applyIdFromFakeName(validationRequestData.nodeData); diff --git a/designer/client/src/actions/reduxTypes.ts b/designer/client/src/actions/reduxTypes.ts index 070a73798fc..57f2b266b80 100644 --- a/designer/client/src/actions/reduxTypes.ts +++ b/designer/client/src/actions/reduxTypes.ts @@ -2,7 +2,7 @@ import { AnyAction, Reducer as ReduxReducer } from "redux"; import { ThunkAction as TA, ThunkDispatch as TD } from "redux-thunk"; import { ActionTypes } from "./actionTypes"; -import { CountsActions, NodeActions, ScenarioActions, SelectionActions, NodeDetailsActions } from "./nk"; +import { CountsActions, NodeActions, ScenarioActions, SelectionActions, NodeDetailsActions, PropertiesActions } from "./nk"; import { UserSettingsActions } from "./nk/userSettings"; import { UiActions } from "./nk/ui/uiActions"; import { SettingsActions } from "./settingsActions"; @@ -25,7 +25,8 @@ type TypedAction = | NotificationActions | DisplayTestResultsDetailsAction | CountsActions - | ScenarioActions; + | ScenarioActions + | PropertiesActions; interface UntypedAction extends AnyAction { type: Exclude; diff --git a/designer/client/src/assets/json/nodeAttributes.json b/designer/client/src/assets/json/nodeAttributes.json index 8d1cc9aa5b7..2e18a54a2ff 100644 --- a/designer/client/src/assets/json/nodeAttributes.json +++ b/designer/client/src/assets/json/nodeAttributes.json @@ -38,9 +38,6 @@ "Aggregate": { "name": "Aggregate" }, - "Properties": { - "name": "Properties" - }, "CustomNode": { "name": "CustomNode" }, diff --git a/designer/client/src/components/ComponentDragPreview.tsx b/designer/client/src/components/ComponentDragPreview.tsx index 26411b58a53..1028f91776b 100644 --- a/designer/client/src/components/ComponentDragPreview.tsx +++ b/designer/client/src/components/ComponentDragPreview.tsx @@ -49,6 +49,10 @@ export const ComponentDragPreview = forwardRef nu willChange: "transform", }); + if (!node) { + return null; + } + return createPortal(
{ addNode(node: NodeType, position: Position): void { if (this.props.isFragment === true) return; - const canAddNode = - this.props.capabilities.editFrontend && NodeUtils.isNode(node) && NodeUtils.isAvailable(node, this.props.processDefinitionData); + const canAddNode = this.props.capabilities.editFrontend && NodeUtils.isAvailable(node, this.props.processDefinitionData); if (canAddNode) { this.props.nodeAdded(node, position); diff --git a/designer/client/src/components/graph/NodeUtils.ts b/designer/client/src/components/graph/NodeUtils.ts index 2d50aa6defd..267eb2a31a4 100644 --- a/designer/client/src/components/graph/NodeUtils.ts +++ b/designer/client/src/components/graph/NodeUtils.ts @@ -1,55 +1,25 @@ /* eslint-disable i18next/no-literal-string */ -import { has, isEmpty, isEqual, uniqBy } from "lodash"; +import { isEqual, uniqBy } from "lodash"; import ProcessUtils from "../../common/ProcessUtils"; -import { - Edge, - EdgeKind, - EdgeType, - FragmentNodeType, - NodeId, - NodeType, - ProcessDefinitionData, - PropertiesType, - ScenarioGraph, - UINodeType, -} from "../../types"; -import { UnknownRecord } from "../../types/common"; +import { Edge, EdgeKind, EdgeType, FragmentNodeType, NodeId, NodeType, ProcessDefinitionData, ScenarioGraph } from "../../types"; import { createEdge } from "../../reducers/graph/utils"; import { Scenario } from "../Process/types"; class NodeUtils { - isNode = (obj: UnknownRecord): obj is NodeType => { - return !isEmpty(obj) && has(obj, "id") && has(obj, "type"); - }; - - nodeType = (node: UINodeType) => { - return node?.type ? node.type : "Properties"; - }; - - nodeIsProperties = (node: UINodeType): node is PropertiesType => { - const type = node && this.nodeType(node); - return type === "Properties"; - }; - - nodeIsFragment = (node: UINodeType): node is FragmentNodeType => { - return this.nodeType(node) === "FragmentInput"; - }; - - isPlainNode = (node: UINodeType) => { - return !isEmpty(node) && !this.nodeIsProperties(node); + nodeIsFragment = (node: NodeType): node is FragmentNodeType => { + return node.type === "FragmentInput"; }; nodeIsJoin = (node: NodeType): boolean => { - return node && this.nodeType(node) === "Join"; + return node && node.type === "Join"; }; nodesFromScenarioGraph = (scenarioGraph: ScenarioGraph): NodeType[] => scenarioGraph.nodes || []; edgesFromScenarioGraph = (scenarioGraph: ScenarioGraph) => scenarioGraph.edges || []; - // For sake of consistency with other nodes, name must be renamed to id - getProcessPropertiesNode = ({ name, scenarioGraph: { properties } }: Scenario, unsavedName?: string) => ({ - id: name || unsavedName, + getProcessProperties = ({ name, scenarioGraph: { properties } }: Scenario, unsavedName?: string) => ({ + name: name || unsavedName, ...properties, }); diff --git a/designer/client/src/components/graph/SelectionContextProvider.tsx b/designer/client/src/components/graph/SelectionContextProvider.tsx index 7643ec2f795..4f19a426c05 100644 --- a/designer/client/src/components/graph/SelectionContextProvider.tsx +++ b/designer/client/src/components/graph/SelectionContextProvider.tsx @@ -56,11 +56,7 @@ function useClipboardParse() { return useCallback( (text) => { const selection = tryParseOrNull(text); - const isValid = - selection?.edges && - selection?.nodes?.every( - (node) => NodeUtils.isNode(node) && NodeUtils.isPlainNode(node) && NodeUtils.isAvailable(node, processDefinitionData), - ); + const isValid = selection?.edges && selection?.nodes?.every((node) => NodeUtils.isAvailable(node, processDefinitionData)); return isValid ? selection : null; }, [processDefinitionData], diff --git a/designer/client/src/components/graph/node-modal/DescriptionField.tsx b/designer/client/src/components/graph/node-modal/DescriptionField.tsx index 78f1d17276f..3bb9a8e1635 100644 --- a/designer/client/src/components/graph/node-modal/DescriptionField.tsx +++ b/designer/client/src/components/graph/node-modal/DescriptionField.tsx @@ -2,13 +2,13 @@ import { NodeField } from "./NodeField"; import { FieldType } from "./editors/field/Field"; import React from "react"; -import { NodeType, NodeValidationError, UINodeType } from "../../../types"; +import { NodeType, NodeValidationError, NodeOrPropertiesType } from "../../../types"; interface DescriptionFieldProps { autoFocus?: boolean; defaultValue?: string; isEditMode?: boolean; - node: UINodeType; + node: NodeOrPropertiesType; readonly?: boolean; renderFieldLabel: (paramName: string) => React.ReactNode; setProperty: (property: K, newValue: NodeType[K], defaultValue?: NodeType[K]) => void; diff --git a/designer/client/src/components/graph/node-modal/DescriptionOnlyContent.tsx b/designer/client/src/components/graph/node-modal/DescriptionOnlyContent.tsx index 842b7acc865..2eea0eb33c1 100644 --- a/designer/client/src/components/graph/node-modal/DescriptionOnlyContent.tsx +++ b/designer/client/src/components/graph/node-modal/DescriptionOnlyContent.tsx @@ -5,18 +5,18 @@ import { DescriptionView } from "../../../containers/DescriptionView"; import { FieldType } from "./editors/field/Field"; import { rowAceEditor } from "./NodeDetailsContent/NodeTableStyled"; import { NodeField } from "./NodeField"; -import { NodeTypeDetailsContentProps, useNodeTypeDetailsContentLogic } from "./NodeTypeDetailsContent"; +import { NodeType, PropertiesType } from "../../../types"; -type DescriptionOnlyContentProps = Pick & { +type DescriptionOnlyContentProps = { + onChange: (property: K, newValue: NodeType[K], defaultValue?: NodeType[K]) => void; + properties: PropertiesType; fieldPath: string; preview?: boolean; }; -export function DescriptionOnlyContent({ fieldPath, preview, node, onChange }: DescriptionOnlyContentProps) { - const { setProperty } = useNodeTypeDetailsContentLogic({ node, onChange }); - +export function DescriptionOnlyContent({ fieldPath, preview, properties, onChange }: DescriptionOnlyContentProps) { if (preview) { - return {get(node, fieldPath)}; + return {get(properties, fieldPath)}; } return ( @@ -31,8 +31,8 @@ export function DescriptionOnlyContent({ fieldPath, preview, node, onChange }: D null} - setProperty={setProperty} - node={node} + setProperty={onChange} + node={properties} isEditMode={true} showValidation={false} readonly={false} diff --git a/designer/client/src/components/graph/node-modal/IdField.tsx b/designer/client/src/components/graph/node-modal/IdField.tsx index c2a0578521f..3e5dc4a2c83 100644 --- a/designer/client/src/components/graph/node-modal/IdField.tsx +++ b/designer/client/src/components/graph/node-modal/IdField.tsx @@ -2,7 +2,7 @@ import { extendErrors, getValidationErrorsForField, uniqueScenarioValueValidator import Field, { FieldType } from "./editors/field/Field"; import React, { useMemo, useState } from "react"; import { useDiffMark } from "./PathsToMark"; -import { NodeType, NodeValidationError, UINodeType } from "../../../types"; +import { NodeType, NodeValidationError, NodeOrPropertiesType } from "../../../types"; import { useSelector } from "react-redux"; import { getProcessNodesIds } from "../../../reducers/selectors/graph"; import NodeUtils from "../NodeUtils"; @@ -11,7 +11,7 @@ import { nodeInput, nodeInputWithError } from "./NodeDetailsContent/NodeTableSty interface IdFieldProps { isEditMode?: boolean; - node: UINodeType; + node: NodeOrPropertiesType; renderFieldLabel: (paramName: string) => React.ReactNode; setProperty?: (property: K, newValue: NodeType[K], defaultValue?: NodeType[K]) => void; showValidation?: boolean; @@ -37,7 +37,7 @@ export function IdField({ isEditMode, node, renderFieldLabel, setProperty, showV const value = useMemo(() => node[FAKE_NAME_PROP_NAME] ?? node[propName], [node]); const marked = useMemo(() => isMarked(FAKE_NAME_PROP_NAME) || isMarked(propName), [isMarked]); - const isUniqueValueValidator = !NodeUtils.nodeIsProperties(node) && uniqueScenarioValueValidator(otherNodes); + const isUniqueValueValidator = uniqueScenarioValueValidator(otherNodes); const fieldErrors = getValidationErrorsForField( isUniqueValueValidator ? extendErrors(errors, value, FAKE_NAME_PROP_NAME, [isUniqueValueValidator]) : errors, diff --git a/designer/client/src/components/graph/node-modal/NodeAdditionalInfoBox.tsx b/designer/client/src/components/graph/node-modal/NodeAdditionalInfoBox.tsx index 56c851d14df..6448c89561d 100644 --- a/designer/client/src/components/graph/node-modal/NodeAdditionalInfoBox.tsx +++ b/designer/client/src/components/graph/node-modal/NodeAdditionalInfoBox.tsx @@ -1,14 +1,17 @@ import React, { useCallback, useEffect, useState } from "react"; -import HttpService from "../../../http/HttpService"; import { useDebounce } from "use-debounce"; -import { NodeType } from "../../../types"; +import { NodeOrPropertiesType } from "../../../types"; import { useSelector } from "react-redux"; import { getProcessName } from "./NodeDetailsContent/selectors"; -import NodeUtils from "../NodeUtils"; import { MarkdownStyled } from "./MarkdownStyled"; interface Props { - node: NodeType; + node: NodeOrPropertiesType; + handleGetAdditionalInfo: ( + processName: string, + node: NodeOrPropertiesType, + controller: AbortController, + ) => Promise; } //Types should match implementations of AdditionalInfo on Backend! @@ -20,7 +23,7 @@ interface MarkdownAdditionalInfo { } export default function NodeAdditionalInfoBox(props: Props): JSX.Element { - const { node } = props; + const { node, handleGetAdditionalInfo } = props; const processName = useSelector(getProcessName); const [additionalInfo, setAdditionalInfo] = useState(null); @@ -29,23 +32,21 @@ export default function NodeAdditionalInfoBox(props: Props): JSX.Element { //we don't wat to query BE on each key pressed (we send node parameters to get additional data) const [debouncedNode] = useDebounce(node, 1000); - const getAdditionalInfo = useCallback((processName: string, debouncedNode: NodeType) => { - const controller = new AbortController(); - const fetch = (node: NodeType) => - NodeUtils.nodeIsProperties(node) - ? HttpService.getPropertiesAdditionalInfo(processName, node, controller) - : HttpService.getNodeAdditionalInfo(processName, node, controller); - - fetch(debouncedNode).then((data) => { - // signal should cancel request, but for some reason it doesn't in dev - if (!controller.signal.aborted && data) { - setAdditionalInfo(data); - } - }); - return () => { - controller.abort(); - }; - }, []); + const getAdditionalInfo = useCallback( + (processName: string, debouncedNode: NodeOrPropertiesType) => { + const controller = new AbortController(); + handleGetAdditionalInfo(processName, debouncedNode, controller).then((data) => { + // signal should cancel request, but for some reason it doesn't in dev + if (!controller.signal.aborted && data) { + setAdditionalInfo(data); + } + }); + return () => { + controller.abort(); + }; + }, + [handleGetAdditionalInfo], + ); useEffect(() => { if (processName) { diff --git a/designer/client/src/components/graph/node-modal/NodeDetailsContent.tsx b/designer/client/src/components/graph/node-modal/NodeDetailsContent.tsx index 387b7f5305e..211f9965642 100644 --- a/designer/client/src/components/graph/node-modal/NodeDetailsContent.tsx +++ b/designer/client/src/components/graph/node-modal/NodeDetailsContent.tsx @@ -11,6 +11,7 @@ import { TestResultsWrapper } from "./TestResultsWrapper"; import { NodeTypeDetailsContent } from "./NodeTypeDetailsContent"; import { DebugNodeInspector } from "./NodeDetailsContent/DebugNodeInspector"; import { useUserSettings } from "../../../common/userSettings"; +import HttpService from "../../../http/HttpService"; export const NodeDetailsContent = ({ node, @@ -47,7 +48,7 @@ export const NodeDetailsContent = ({ showSwitch={showSwitch} /> - + {userSettings["debug.nodesAsJson"] && } ); diff --git a/designer/client/src/components/graph/node-modal/NodeField.tsx b/designer/client/src/components/graph/node-modal/NodeField.tsx index 26695d020ab..f2860b5af0b 100644 --- a/designer/client/src/components/graph/node-modal/NodeField.tsx +++ b/designer/client/src/components/graph/node-modal/NodeField.tsx @@ -3,7 +3,7 @@ import { getValidationErrorsForField } from "./editors/Validators"; import { get, isEmpty } from "lodash"; import React from "react"; import { useDiffMark } from "./PathsToMark"; -import { NodeType, NodeValidationError, UINodeType } from "../../../types"; +import { NodeType, NodeValidationError, NodeOrPropertiesType } from "../../../types"; import { nodeInput, nodeInputWithError } from "./NodeDetailsContent/NodeTableStyled"; import { cx } from "@emotion/css"; @@ -14,7 +14,7 @@ type NodeFieldProps = { fieldName: N; fieldType: FieldType; isEditMode?: boolean; - node: UINodeType; + node: NodeOrPropertiesType; readonly?: boolean; renderFieldLabel: (paramName: string) => React.ReactNode; setProperty: (property: K, newValue: NodeType[K], defaultValue?: NodeType[K]) => void; diff --git a/designer/client/src/components/graph/node-modal/NodeTypeDetailsContent.tsx b/designer/client/src/components/graph/node-modal/NodeTypeDetailsContent.tsx index ffee5d55076..639ab4f3ef5 100644 --- a/designer/client/src/components/graph/node-modal/NodeTypeDetailsContent.tsx +++ b/designer/client/src/components/graph/node-modal/NodeTypeDetailsContent.tsx @@ -4,7 +4,6 @@ import { useDispatch, useSelector } from "react-redux"; import { nodeDetailsClosed, nodeDetailsOpened, validateNodeData } from "../../../actions/nk"; import { getProcessDefinitionData } from "../../../reducers/selectors/settings"; import { Edge, NodeType, NodeValidationError, PropertiesType } from "../../../types"; -import NodeUtils from "../NodeUtils"; import { CustomNode } from "./customNode"; import { EnricherProcessor } from "./enricherProcessor"; import { ParamFieldLabel } from "./FieldLabel"; @@ -24,7 +23,6 @@ import { } from "./NodeDetailsContent/selectors"; import { generateUUIDs } from "./nodeUtils"; import { adjustParameters } from "./ParametersUtils"; -import { Properties } from "./properties"; import { Sink } from "./sink"; import { Source } from "./source"; import { Split } from "./split"; @@ -179,7 +177,7 @@ export function NodeTypeDetailsContent({ errors, showSwitch, ...props }: NodeTyp showValidation, } = useNodeTypeDetailsContentLogic(props); - switch (NodeUtils.nodeType(node)) { + switch (node.type) { case "Source": return ( ); - case "Properties": - return ( - - ); default: return ( , definitions: UIParam }; const parametersPath = (node) => { - switch (NodeUtils.nodeType(node)) { + switch (node.type) { case "CustomNode": return `parameters`; case "Join": diff --git a/designer/client/src/components/graph/node-modal/editors/expression/JsonEditor.tsx b/designer/client/src/components/graph/node-modal/editors/expression/JsonEditor.tsx index 8d6cc4d0392..dee9edd5840 100644 --- a/designer/client/src/components/graph/node-modal/editors/expression/JsonEditor.tsx +++ b/designer/client/src/components/graph/node-modal/editors/expression/JsonEditor.tsx @@ -52,6 +52,7 @@ export const JsonEditor: SimpleEditor = ({ sx={{ position: "relative" }} > { editMode?: boolean; @@ -20,32 +21,40 @@ interface DescriptionDialogProps extends WindowContentProps(() => { + if (readOnly) return false; if (previewMode && !isTouched) return false; - return _apply; - }, [_apply, previewMode, isTouched]); + return { + title: t("dialog.button.apply", "apply"), + action: async () => { + await dispatch(editProperties(scenario, editedProperties)); + close(); + }, + disabled: !editedProperties.name?.length, + }; + }, [readOnly, previewMode, isTouched, t, editedProperties, dispatch, scenario, close]); const cancel = useMemo(() => { if (previewMode && !isTouched) return false; - if (!_cancel || !get(node, fieldPath)) return _cancel; return { - ..._cancel, + title: t("dialog.button.cancel", "cancel"), + className: LoadingButtonTypes.secondaryButton, action: () => { - onChange(node); + handleSetEditedProperties(fieldPath, currentProperties.additionalFields.description); setPreviewMode(true); }, }; - }, [previewMode, isTouched, _cancel, onChange, node]); + }, [previewMode, isTouched, currentProperties, t, handleSetEditedProperties]); const preview = useMemo(() => { if (!isTouched) return false; @@ -75,22 +84,24 @@ function DescriptionDialog(props: DescriptionDialogProps): JSX.Element { ); const HeaderButtonZoom = (props) => ( <> - setPreviewMode(false)} name="edit"> - - + {readOnly ? null : ( + setPreviewMode(false)} name="edit"> + + + )} ); return { Header, HeaderTitle, HeaderButtonZoom }; - }, [isTouched, previewMode]); + }, [isTouched, previewMode, readOnly]); return ( - + ); } diff --git a/designer/client/src/components/graph/node-modal/node/NodeGroupContent.tsx b/designer/client/src/components/graph/node-modal/node/NodeGroupContent.tsx index e5ba806c6d2..08ade438566 100644 --- a/designer/client/src/components/graph/node-modal/node/NodeGroupContent.tsx +++ b/designer/client/src/components/graph/node-modal/node/NodeGroupContent.tsx @@ -5,7 +5,7 @@ import { Edge, NodeType } from "../../../../types"; import NodeUtils from "../../NodeUtils"; import { ContentSize } from "./ContentSize"; import { FragmentContent } from "./FragmentContent"; -import { getNodeErrors, getPropertiesErrors } from "./selectors"; +import { getNodeErrors } from "./selectors"; import { RootState } from "../../../../reducers"; import { NodeDetailsContent } from "../NodeDetailsContent"; @@ -17,7 +17,7 @@ interface Props { export function NodeGroupContent({ node, edges, onChange }: Props): JSX.Element { const errors = useSelector((state: RootState) => { - return node.type ? getNodeErrors(state, node.id) : getPropertiesErrors(state); + return getNodeErrors(state, node.id); }); return ( diff --git a/designer/client/src/components/graph/node-modal/nodeDetails/NodeDetailsModalHeader.tsx b/designer/client/src/components/graph/node-modal/nodeDetails/NodeDetailsModalHeader.tsx index 01bbcdae547..d1c34afb64f 100644 --- a/designer/client/src/components/graph/node-modal/nodeDetails/NodeDetailsModalHeader.tsx +++ b/designer/client/src/components/graph/node-modal/nodeDetails/NodeDetailsModalHeader.tsx @@ -4,7 +4,6 @@ import { useSelector } from "react-redux"; import nodeAttributes from "../../../../assets/json/nodeAttributes.json"; import { getProcessDefinitionData } from "../../../../reducers/selectors/settings"; import { NodeType } from "../../../../types"; -import NodeUtils from "../../NodeUtils"; import ProcessUtils from "../../../../common/ProcessUtils"; import { ModalHeader, WindowHeaderIconStyled } from "./NodeDetailsStyled"; import { NodeDocs } from "./SubHeader"; @@ -20,7 +19,7 @@ const findNodeClass = (node: NodeType) => nodeClassProperties.find((property) => has(node, property)), ); -const getNodeAttributes = (node: NodeType) => nodeAttributes[NodeUtils.nodeType(node)]; +const getNodeAttributes = (node: NodeType) => nodeAttributes[node.type]; type IconModalHeaderProps = PropsWithChildren<{ startIcon?: React.ReactElement; @@ -51,13 +50,11 @@ export const getNodeDetailsModalTitle = (node: NodeType): string => { }; export const NodeDetailsModalSubheader = ({ node }: { node: NodeType }): ReactElement => { - const { components = {}, scenarioProperties } = useSelector(getProcessDefinitionData); + const { components = {} } = useSelector(getProcessDefinitionData); const docsUrl = useMemo(() => { - return NodeUtils.nodeIsProperties(node) - ? scenarioProperties?.docsUrl - : ProcessUtils.extractComponentDefinition(node, components)?.docsUrl; - }, [components, node, scenarioProperties]); + return ProcessUtils.extractComponentDefinition(node, components)?.docsUrl; + }, [components, node]); const nodeClass = findNodeClass(node); @@ -65,5 +62,5 @@ export const NodeDetailsModalSubheader = ({ node }: { node: NodeType }): ReactEl }; export const NodeDetailsModalIcon = styled(WindowHeaderIconStyled.withComponent(ComponentIcon))(({ node, theme }) => ({ - backgroundColor: theme.palette.custom.getNodeStyles(node).fill, + backgroundColor: theme.palette.custom.getNodeStyles(node.type).fill, })); diff --git a/designer/client/src/components/graph/node-modal/properties.tsx b/designer/client/src/components/graph/node-modal/properties.tsx deleted file mode 100644 index 05c85987b54..00000000000 --- a/designer/client/src/components/graph/node-modal/properties.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { NodeType, NodeValidationError, PropertiesType } from "../../../types"; -import { useSelector } from "react-redux"; -import { getScenarioPropertiesConfig } from "./NodeDetailsContent/selectors"; -import React, { useMemo } from "react"; -import { sortBy } from "lodash"; -import { IdField } from "./IdField"; -import ScenarioProperty from "./ScenarioProperty"; -import { DescriptionField } from "./DescriptionField"; -import { FieldLabel } from "./FieldLabel"; -import { FieldType } from "./editors/field/Field"; -import { NodeField } from "./NodeField"; - -interface Props { - isEditMode?: boolean; - node: PropertiesType; - renderFieldLabel: (paramName: string) => JSX.Element; - setProperty: (property: K, newValue: NodeType[K], defaultValue?: NodeType[K]) => void; - showSwitch?: boolean; - errors?: NodeValidationError[]; - showValidation?: boolean; -} - -export function Properties({ - errors = [], - isEditMode, - node, - renderFieldLabel, - setProperty, - showSwitch, - showValidation, -}: Props): JSX.Element { - const scenarioProperties = useSelector(getScenarioPropertiesConfig); - const scenarioPropertiesConfig = scenarioProperties?.propertiesConfig ?? {}; - - //fixme move this configuration to some better place? - //we sort by name, to have predictable order of properties (should be replaced by defining order in configuration) - const scenarioPropertiesSorted = useMemo( - () => sortBy(Object.entries(scenarioPropertiesConfig), ([name]) => name), - [scenarioPropertiesConfig], - ); - - return ( - <> - - {scenarioPropertiesSorted.map(([propName, propConfig]) => ( - } - editedNode={node} - readOnly={!isEditMode} - /> - ))} - - - - ); -} diff --git a/designer/client/src/components/modals/CompareVersionsDialog.tsx b/designer/client/src/components/modals/CompareVersionsDialog.tsx index 7f95f6c71a5..5fb527755a6 100644 --- a/designer/client/src/components/modals/CompareVersionsDialog.tsx +++ b/designer/client/src/components/modals/CompareVersionsDialog.tsx @@ -22,6 +22,7 @@ import { Option, TypeSelect } from "../graph/node-modal/fragment-input-definitio import { WindowHeaderIconStyled } from "../graph/node-modal/nodeDetails/NodeDetailsStyled"; import Icon from "../../assets/img/toolbarButtons/compare.svg"; import i18next from "i18next"; +import { PropertiesForm } from "../properties"; const initState: State = { otherVersion: null, @@ -181,7 +182,7 @@ const VersionsForm = ({ predefinedOtherVersion }: Props) => { }; const printProperties = (property) => { - return property ? :
Properties not present
; + return property ? :
Properties not present
; }; const versionOptions: Option[] = useMemo(() => { diff --git a/designer/client/src/components/modals/PropertiesDialog.tsx b/designer/client/src/components/modals/PropertiesDialog.tsx new file mode 100644 index 00000000000..84eb86092ae --- /dev/null +++ b/designer/client/src/components/modals/PropertiesDialog.tsx @@ -0,0 +1,120 @@ +import { WindowButtonProps, WindowContentProps } from "@touk/window-manager"; +import { WindowContent, WindowKind } from "../../windowManager"; +import { css } from "@emotion/css"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { LoadingButtonTypes } from "../../windowManager/LoadingButton"; +import { useTranslation } from "react-i18next"; +import { editProperties } from "../../actions/nk"; +import { useDispatch, useSelector } from "react-redux"; +import { getPropertiesErrors, getReadOnly } from "../graph/node-modal/node/selectors"; +import { NodeValidationError, PropertiesType } from "../../types"; +import { getProcessName, getScenarioPropertiesConfig } from "../graph/node-modal/NodeDetailsContent/selectors"; +import { debounce, isEqual } from "lodash"; +import { getProcessUnsavedNewName, getScenario } from "../../reducers/selectors/graph"; +import NodeUtils from "../graph/NodeUtils"; +import { set } from "lodash/fp"; +import HttpService from "../../http/HttpService"; +import { NodeDocs } from "../graph/node-modal/nodeDetails/SubHeader"; +import PropertiesSvg from "../../assets/img/properties.svg"; +import { styled } from "@mui/material"; +import { WindowHeaderIconStyled } from "../graph/node-modal/nodeDetails/NodeDetailsStyled"; +import { PropertiesForm } from "../properties"; +import { ContentSize } from "../graph/node-modal/node/ContentSize"; +import { RootState } from "../../reducers"; + +export const usePropertiesState = () => { + const scenario = useSelector(getScenario); + const name = useSelector(getProcessUnsavedNewName); + const currentProperties = useMemo(() => NodeUtils.getProcessProperties(scenario, name), [name, scenario]); + const [editedProperties, setEditedProperties] = useState(currentProperties); + const isTouched = useMemo(() => !isEqual(currentProperties, editedProperties), [currentProperties, editedProperties]); + + const handleSetEditedProperties = useCallback((label: string | number, value: string) => { + setEditedProperties((prevState) => set(label, value, prevState) as unknown as typeof editedProperties); + }, []); + + return { currentProperties, editedProperties, handleSetEditedProperties, isTouched }; +}; + +export const NodeDetailsModalIcon = styled(WindowHeaderIconStyled.withComponent(PropertiesSvg))(({ theme }) => ({ + backgroundColor: theme.palette.custom.getWindowStyles(WindowKind.editProperties).backgroundColor, +})); + +const PropertiesDialog = ({ ...props }: WindowContentProps) => { + const isEditMode = !useSelector((s: RootState) => getReadOnly(s, false)); + + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const globalPropertiesErrors = useSelector(getPropertiesErrors); + const scenarioProperties = useSelector(getScenarioPropertiesConfig); + const scenario = useSelector(getScenario); + const scenarioName = useSelector(getProcessName); + + const [errors, setErrors] = useState(isEditMode ? globalPropertiesErrors : []); + const { editedProperties, handleSetEditedProperties } = usePropertiesState(); + const showSwitch = false; + + const debouncedValidateProperties = useMemo(() => { + return debounce((scenarioName, additionalFields, id) => { + HttpService.validateProperties(scenarioName, { additionalFields: additionalFields, name: id }).then((data) => { + if (data) { + setErrors(data.validationErrors); + } + }); + }, 500); + }, []); + + const apply = useMemo(() => { + return { + title: t("dialog.button.apply", "apply"), + action: async () => { + await dispatch(editProperties(scenario, editedProperties)); + props.close(); + }, + }; + }, [dispatch, editedProperties, props, scenario, t]); + + const cancel = useMemo(() => { + return { + title: t("dialog.button.cancel", "cancel"), + action: () => props.close(), + className: LoadingButtonTypes.secondaryButton, + }; + }, [props, t]); + + useEffect(() => { + if (!isEditMode) { + return; + } + + debouncedValidateProperties(scenarioName, editedProperties.additionalFields, editedProperties.name); + }, [debouncedValidateProperties, isEditMode, editedProperties.additionalFields, editedProperties.name, scenarioName]); + + return ( + } + subheader={} + classnames={{ + content: css({ minHeight: "100%", display: "flex", ">div": { flex: 1 }, position: "relative" }), + }} + > +
+ + + +
+
+ ); +}; + +export default PropertiesDialog; diff --git a/designer/client/src/components/properties/NameField.tsx b/designer/client/src/components/properties/NameField.tsx new file mode 100644 index 00000000000..3188fe23417 --- /dev/null +++ b/designer/client/src/components/properties/NameField.tsx @@ -0,0 +1,38 @@ +import React, { ComponentProps } from "react"; +import Field, { FieldType } from "../graph/node-modal/editors/field/Field"; +import { isEmpty } from "lodash"; +import { nodeInput, nodeInputWithError } from "../graph/node-modal/NodeDetailsContent/NodeTableStyled"; +import { getValidationErrorsForField } from "../graph/node-modal/editors/Validators"; +import { FieldLabel } from "../graph/node-modal/FieldLabel"; +import { NodeValidationError } from "../../types"; +import { PropertiesForm } from "./PropertiesForm"; +import { useDiffMark } from "../graph/node-modal/PathsToMark"; + +const FAKE_NAME_PROP_NAME = "$id"; +const fieldName = "name"; + +interface Props { + readOnly: boolean; + errors: NodeValidationError[]; + value: string; + onChange: ComponentProps["handleSetEditedProperties"]; +} +export const NameField = ({ readOnly, errors, value, onChange }: Props) => { + const [isMarked] = useDiffMark(); + return ( + onChange(fieldName, newValue.toString())} + readOnly={readOnly} + className={isEmpty(errors) ? nodeInput : `${nodeInput} ${nodeInputWithError}`} + // TODO: we need to change this fieldName on the backend from $id to name + fieldErrors={getValidationErrorsForField(errors, FAKE_NAME_PROP_NAME)} + value={value} + autoFocus + > + + + ); +}; diff --git a/designer/client/src/components/properties/PropertiesForm.tsx b/designer/client/src/components/properties/PropertiesForm.tsx new file mode 100644 index 00000000000..a4675cc09e4 --- /dev/null +++ b/designer/client/src/components/properties/PropertiesForm.tsx @@ -0,0 +1,72 @@ +import React, { ComponentProps, useMemo } from "react"; +import { NodeTable } from "../graph/node-modal/NodeDetailsContent/NodeTable"; +import { FieldType } from "../graph/node-modal/editors/field/Field"; +import { sortBy } from "lodash"; +import { FieldLabel } from "../graph/node-modal/FieldLabel"; +import ScenarioProperty from "./ScenarioProperty"; +import { DescriptionField } from "../graph/node-modal/DescriptionField"; +import { NodeField } from "../graph/node-modal/NodeField"; +import NodeAdditionalInfoBox from "../graph/node-modal/NodeAdditionalInfoBox"; +import HttpService from "../../http/HttpService"; +import { NodeValidationError, PropertiesType } from "../../types"; +import { useSelector } from "react-redux"; +import { getScenarioPropertiesConfig } from "../graph/node-modal/NodeDetailsContent/selectors"; +import { NameField } from "./NameField"; + +interface Props { + errors?: NodeValidationError[]; + handleSetEditedProperties?: ComponentProps["onChange"]; + editedProperties: PropertiesType; + showSwitch?: boolean; +} +export const PropertiesForm = ({ errors = [], handleSetEditedProperties, editedProperties, showSwitch = false }: Props) => { + const readOnly = !handleSetEditedProperties; + const scenarioProperties = useSelector(getScenarioPropertiesConfig); + const scenarioPropertiesConfig = useMemo(() => scenarioProperties?.propertiesConfig ?? {}, [scenarioProperties?.propertiesConfig]); + + //we sort by name, to have predictable order of properties (should be replaced by defining order in configuration) + const scenarioPropertiesSorted = useMemo( + () => sortBy(Object.entries(scenarioPropertiesConfig), ([name]) => name), + [scenarioPropertiesConfig], + ); + + return ( + + + {scenarioPropertiesSorted.map(([propName, propConfig]) => ( + } + editedNode={editedProperties} + readOnly={readOnly} + /> + ))} + } + setProperty={handleSetEditedProperties} + errors={errors} + /> + } + setProperty={handleSetEditedProperties} + errors={errors} + fieldType={FieldType.checkbox} + fieldName={"additionalFields.showDescription"} + description={"Show description each time scenario is opened"} + /> + + + ); +}; diff --git a/designer/client/src/components/graph/node-modal/ScenarioProperty.tsx b/designer/client/src/components/properties/ScenarioProperty.tsx similarity index 86% rename from designer/client/src/components/graph/node-modal/ScenarioProperty.tsx rename to designer/client/src/components/properties/ScenarioProperty.tsx index d3fd7aabd40..0afe4e59c0f 100644 --- a/designer/client/src/components/graph/node-modal/ScenarioProperty.tsx +++ b/designer/client/src/components/properties/ScenarioProperty.tsx @@ -1,9 +1,9 @@ import { get } from "lodash"; -import EditableEditor from "./editors/EditableEditor"; +import EditableEditor from "../graph/node-modal/editors/EditableEditor"; import React, { useCallback } from "react"; -import { ExpressionLang } from "./editors/expression/types"; -import { getValidationErrorsForField } from "./editors/Validators"; -import { NodeValidationError, PropertiesType } from "../../../types"; +import { ExpressionLang } from "../graph/node-modal/editors/expression/types"; +import { getValidationErrorsForField } from "../graph/node-modal/editors/Validators"; +import { NodeValidationError, PropertiesType } from "../../types"; export interface ScenarioPropertyConfig { editor: any; diff --git a/designer/client/src/components/properties/index.ts b/designer/client/src/components/properties/index.ts new file mode 100644 index 00000000000..4e3ddfe80a5 --- /dev/null +++ b/designer/client/src/components/properties/index.ts @@ -0,0 +1 @@ +export { PropertiesForm } from "./PropertiesForm"; diff --git a/designer/client/src/components/tips/error/ErrorTips.tsx b/designer/client/src/components/tips/error/ErrorTips.tsx index 15162a0523d..bbdc87a8540 100644 --- a/designer/client/src/components/tips/error/ErrorTips.tsx +++ b/designer/client/src/components/tips/error/ErrorTips.tsx @@ -1,8 +1,9 @@ import React, { useMemo } from "react"; -import { concat, isEmpty } from "lodash"; +import { isEmpty } from "lodash"; import { Props } from "./Errors"; import NodeErrorsLinkSection from "./NodeErrorsLinkSection"; import i18next from "i18next"; +import { ScenarioPropertiesSection } from "./ScenarioPropertiesSection"; export const ErrorTips = ({ errors, showDetails, scenario }: Props) => { const { globalErrors, processPropertiesErrors, invalidNodes } = errors; @@ -33,11 +34,12 @@ export const ErrorTips = ({ errors, showDetails, scenario }: Props) => {
{globalErrorsLinkSections} + {!isEmpty(processPropertiesErrors) && }
); }; diff --git a/designer/client/src/components/tips/error/Errors.tsx b/designer/client/src/components/tips/error/Errors.tsx index 759cddbbd20..98abf37bb0b 100644 --- a/designer/client/src/components/tips/error/Errors.tsx +++ b/designer/client/src/components/tips/error/Errors.tsx @@ -1,13 +1,13 @@ import React, { SyntheticEvent } from "react"; import { v4 as uuid4 } from "uuid"; import { HeaderIcon } from "./HeaderIcon"; -import { NodeType, ValidationErrors } from "../../../types"; +import { NodeOrPropertiesType, ValidationErrors } from "../../../types"; import { ErrorTips } from "./ErrorTips"; import { Scenario } from "../../Process/types"; export interface Props { errors: ValidationErrors; - showDetails: (event: SyntheticEvent, details: NodeType) => void; + showDetails: (event: SyntheticEvent, details: NodeOrPropertiesType) => void; scenario: Scenario; } diff --git a/designer/client/src/components/tips/error/NodeErrorLink.tsx b/designer/client/src/components/tips/error/NodeErrorLink.tsx index c2443f51e59..9baf352a2d4 100644 --- a/designer/client/src/components/tips/error/NodeErrorLink.tsx +++ b/designer/client/src/components/tips/error/NodeErrorLink.tsx @@ -3,44 +3,29 @@ import { NavLink } from "react-router-dom"; import { css, cx } from "@emotion/css"; import { NodeId } from "../../../types"; import Color from "color"; -import { Typography, useTheme } from "@mui/material"; +import { useTheme } from "@mui/material"; +import { ErrorLinkStyle } from "./styled"; export const NodeErrorLink = (props: { onClick: MouseEventHandler; nodeId: NodeId; disabled?: boolean }) => { const { onClick, nodeId, disabled } = props; const theme = useTheme(); - const styles = css({ - whiteSpace: "normal", - fontWeight: 600, - color: theme.palette.error.light, - "a&": { - "&:hover": { - color: Color(theme.palette.error.main).lighten(0.25).hex(), - }, - "&:focus": { - color: theme.palette.error.main, - textDecoration: "none", - }, - }, - }); - return disabled ? ( - {nodeId} - + ) : ( - + {/* blank values don't render as links so this is a workaround */} {nodeId.trim() === "" ? "blank" : nodeId} - + ); }; diff --git a/designer/client/src/components/tips/error/NodeErrorsLinkSection.tsx b/designer/client/src/components/tips/error/NodeErrorsLinkSection.tsx index 536fcbd5f51..f67db205140 100644 --- a/designer/client/src/components/tips/error/NodeErrorsLinkSection.tsx +++ b/designer/client/src/components/tips/error/NodeErrorsLinkSection.tsx @@ -1,8 +1,6 @@ import { isEmpty } from "lodash"; import React, { SyntheticEvent } from "react"; -import { useSelector } from "react-redux"; import NodeUtils from "../../graph/NodeUtils"; -import { getProcessUnsavedNewName } from "../../../reducers/selectors/graph"; import { NodeId, NodeType } from "../../../types"; import { ErrorHeader } from "./ErrorHeader"; import { NodeErrorLink } from "./NodeErrorLink"; @@ -17,7 +15,6 @@ interface NodeErrorsLinkSectionProps { export default function NodeErrorsLinkSection(props: NodeErrorsLinkSectionProps): JSX.Element { const { nodeIds, message, showDetails, scenario } = props; - const unsavedName = useSelector(getProcessUnsavedNewName); const separator = ", "; return ( @@ -25,10 +22,7 @@ export default function NodeErrorsLinkSection(props: NodeErrorsLinkSectionProps)
{nodeIds.map((nodeId, index) => { - const details = - nodeId === "properties" - ? NodeUtils.getProcessPropertiesNode(scenario, unsavedName) - : NodeUtils.getNodeById(nodeId, scenario.scenarioGraph); + const details = NodeUtils.getNodeById(nodeId, scenario.scenarioGraph); return ( showDetails(event, details)} nodeId={nodeId} disabled={!details} /> diff --git a/designer/client/src/components/tips/error/ScenarioPropertiesSection.tsx b/designer/client/src/components/tips/error/ScenarioPropertiesSection.tsx new file mode 100644 index 00000000000..80b347182c3 --- /dev/null +++ b/designer/client/src/components/tips/error/ScenarioPropertiesSection.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { ErrorHeader } from "./ErrorHeader"; +import i18next from "i18next"; +import { ErrorLinkStyle } from "./styled"; +import { NavLink } from "react-router-dom"; +import { useOpenProperties } from "../../toolbars/scenarioActions/buttons/PropertiesButton"; +import { useTranslation } from "react-i18next"; + +export const ScenarioPropertiesSection = () => { + const { t } = useTranslation(); + const openProperties = useOpenProperties(); + return ( + <> + + + {t("errors.propertiesText", "properties")} + + + ); +}; diff --git a/designer/client/src/components/tips/error/styled.ts b/designer/client/src/components/tips/error/styled.ts new file mode 100644 index 00000000000..a976936e8a1 --- /dev/null +++ b/designer/client/src/components/tips/error/styled.ts @@ -0,0 +1,18 @@ +import Color from "color"; +import { styled, Typography, TypographyProps } from "@mui/material"; +import { NavLink } from "react-router-dom"; + +export const ErrorLinkStyle = styled(Typography)>(({ theme }) => ({ + whiteSpace: "normal", + fontWeight: 600, + color: theme.palette.error.light, + "a&": { + "&:hover": { + color: Color(theme.palette.error.main).lighten(0.25).hex(), + }, + "&:focus": { + color: theme.palette.error.main, + textDecoration: "none", + }, + }, +})); diff --git a/designer/client/src/components/toolbars/creator/Icon.tsx b/designer/client/src/components/toolbars/creator/Icon.tsx index c7668ddccdc..11d40d5bcd9 100644 --- a/designer/client/src/components/toolbars/creator/Icon.tsx +++ b/designer/client/src/components/toolbars/creator/Icon.tsx @@ -1,5 +1,5 @@ -import PropertiesSvg from "../../../assets/img/properties.svg"; import React from "react"; +import PlaceholderIcon from "../../../components/common/error-boundary/images/placeholder-icon.svg"; type IconProps = { className?: string; @@ -8,7 +8,7 @@ type IconProps = { export function Icon({ className, src }: IconProps) { if (!src) { - return ; + return ; } return ( diff --git a/designer/client/src/components/toolbars/scenarioActions/buttons/PropertiesButton.tsx b/designer/client/src/components/toolbars/scenarioActions/buttons/PropertiesButton.tsx index 48002a8b4ca..2c47a5633f5 100644 --- a/designer/client/src/components/toolbars/scenarioActions/buttons/PropertiesButton.tsx +++ b/designer/client/src/components/toolbars/scenarioActions/buttons/PropertiesButton.tsx @@ -1,23 +1,22 @@ -import { WindowType } from "@touk/window-manager"; -import React, { useCallback, useMemo } from "react"; +import React, { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useSelector } from "react-redux"; import Icon from "../../../../assets/img/toolbarButtons/properties.svg"; -import { getProcessUnsavedNewName, getScenario, hasError, hasPropertiesErrors } from "../../../../reducers/selectors/graph"; -import { useWindows } from "../../../../windowManager"; -import { NodeViewMode } from "../../../../windowManager/useWindows"; -import NodeUtils from "../../../graph/NodeUtils"; +import { hasError, hasPropertiesErrors } from "../../../../reducers/selectors/graph"; +import { useWindows, WindowKind } from "../../../../windowManager"; import { ToolbarButton } from "../../../toolbarComponents/toolbarButtons"; import { ToolbarButtonProps } from "../../types"; export function useOpenProperties() { - const { openNodeWindow } = useWindows(); - const scenario = useSelector(getScenario); - const name = useSelector(getProcessUnsavedNewName); - const processProperties = useMemo(() => NodeUtils.getProcessPropertiesNode(scenario, name), [name, scenario]); + const { open } = useWindows(); return useCallback( - (mode?: NodeViewMode, layout?: WindowType["layoutData"]) => openNodeWindow(processProperties, scenario, mode, layout), - [openNodeWindow, processProperties, scenario], + () => + open({ + kind: WindowKind.editProperties, + isResizable: true, + shouldCloseOnEsc: false, + }), + [open], ); } diff --git a/designer/client/src/components/toolbars/search/utils.ts b/designer/client/src/components/toolbars/search/utils.ts index e379a699978..8998c5f37f3 100644 --- a/designer/client/src/components/toolbars/search/utils.ts +++ b/designer/client/src/components/toolbars/search/utils.ts @@ -1,5 +1,5 @@ import { Edge, NodeType } from "../../../types"; -import { concat, keys, uniq } from "lodash"; +import { uniq } from "lodash"; import { useSelector } from "react-redux"; import { isEqual } from "lodash"; import { getScenario } from "../../../reducers/selectors/graph"; diff --git a/designer/client/src/containers/ScenarioDescription.tsx b/designer/client/src/containers/ScenarioDescription.tsx index a961fd7d269..2c39983044a 100644 --- a/designer/client/src/containers/ScenarioDescription.tsx +++ b/designer/client/src/containers/ScenarioDescription.tsx @@ -1,29 +1,68 @@ import { Description } from "@mui/icons-material"; import { IconButton } from "@mui/material"; -import React, { useCallback, useEffect, useRef } from "react"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useSelector } from "react-redux"; -import { useOpenProperties } from "../components/toolbars/scenarioActions/buttons/PropertiesButton"; -import { getScenarioDescription } from "../reducers/selectors/graph"; -import { NodeViewMode } from "../windowManager/useWindows"; +import { getProcessUnsavedNewName, getScenario, getScenarioDescription } from "../reducers/selectors/graph"; +import { useWindows, WindowKind } from "../windowManager"; +import { WindowType } from "@touk/window-manager"; +import { Scenario } from "../components/Process/types"; +import NodeUtils from "../components/graph/NodeUtils"; +import { NodeOrPropertiesType } from "../types"; + +export const DescriptionViewMode = { + descriptionView: "description", + descriptionEdit: "descriptionEdit", +} as const; + +export type DescriptionViewMode = (typeof DescriptionViewMode)[keyof typeof DescriptionViewMode]; + +export function useOpenDescription() { + const { open } = useWindows(); + return useCallback( + ( + node: NodeOrPropertiesType, + scenario: Scenario, + descriptionViewMode?: DescriptionViewMode, + layoutData?: WindowType["layoutData"], + ) => + open({ + kind: descriptionViewMode === DescriptionViewMode.descriptionEdit ? WindowKind.editDescription : WindowKind.viewDescription, + isResizable: true, + shouldCloseOnEsc: false, + meta: { node, scenario }, + layoutData, + }), + [open], + ); +} export const ScenarioDescription = () => { const [description, showDescription] = useSelector(getScenarioDescription); + const scenario = useSelector(getScenario); + const name = useSelector(getProcessUnsavedNewName); + const processProperties = useMemo(() => NodeUtils.getProcessProperties(scenario, name), [name, scenario]); - const openProperties = useOpenProperties(); + const openDescription = useOpenDescription(); const ref = useRef(); - const openDescription = useCallback(() => { + const handleOpenDescription = useCallback(() => { + if (!ref.current) return; const { top, left } = ref.current.getBoundingClientRect(); - openProperties(description ? NodeViewMode.descriptionView : NodeViewMode.descriptionEdit, { top, left, width: 600 }); - }, [description, openProperties]); + openDescription( + processProperties, + scenario, + description ? DescriptionViewMode.descriptionView : DescriptionViewMode.descriptionEdit, + { top, left, width: 600 }, + ); + }, [description, openDescription, processProperties, scenario]); useEffect( () => { if (description && showDescription) { // delaying this is a cheap way to wait for stable positions - setTimeout(openDescription, 750); + setTimeout(handleOpenDescription, 750); } }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -39,7 +78,7 @@ export const ScenarioDescription = () => { { return getNodeIds() - .map( - (id) => - NodeUtils.getNodeById(id, scenario.scenarioGraph) ?? - (scenario.name === id && NodeUtils.getProcessPropertiesNode(scenario)), - ) + .map((id) => NodeUtils.getNodeById(id, scenario.scenarioGraph)) .filter(Boolean) .map((node) => openNodeWindow(node, scenario)); }, diff --git a/designer/client/src/containers/theme/darkModePalette.ts b/designer/client/src/containers/theme/darkModePalette.ts index 5b0ab57ec8f..2d10ae01f3a 100644 --- a/designer/client/src/containers/theme/darkModePalette.ts +++ b/designer/client/src/containers/theme/darkModePalette.ts @@ -85,9 +85,6 @@ export const darkModePalette: PaletteOptions = { Aggregate: { fill: "#e892bd", }, - Properties: { - fill: "#46ca94", - }, CustomNode: { fill: "#19A49D", }, @@ -107,6 +104,10 @@ export const darkModePalette: PaletteOptions = { backgroundColor: "white", color: "black", }, + editProperties: { + backgroundColor: "#46ca94", + color: "white", + }, default: { backgroundColor: "#2D8E54", color: "white", diff --git a/designer/client/src/containers/theme/lightModePalette.ts b/designer/client/src/containers/theme/lightModePalette.ts index fb952f5cf62..3c682514d15 100644 --- a/designer/client/src/containers/theme/lightModePalette.ts +++ b/designer/client/src/containers/theme/lightModePalette.ts @@ -86,9 +86,6 @@ export const lightModePalette: PaletteOptions = { Aggregate: { fill: "#e892bd", }, - Properties: { - fill: "#46ca94", - }, CustomNode: { fill: "#19A49D", }, @@ -102,6 +99,10 @@ export const lightModePalette: PaletteOptions = { windows: { compareVersions: { backgroundColor: "#1ba1af", color: "white" }, customAction: { backgroundColor: "white", color: "black" }, + editProperties: { + backgroundColor: "#46ca94", + color: "white", + }, default: { backgroundColor: "#2D8E54", color: "white" }, }, }, diff --git a/designer/client/src/containers/theme/nuTheme.tsx b/designer/client/src/containers/theme/nuTheme.tsx index 16f375e49fc..35292c65956 100644 --- a/designer/client/src/containers/theme/nuTheme.tsx +++ b/designer/client/src/containers/theme/nuTheme.tsx @@ -74,14 +74,16 @@ const custom = { const extendWithHelpers = (custom: CustomPalette) => ({ ...custom, - getNodeStyles: function (this: CustomPalette, node: NodeType) { - return this.nodes[NodeUtils.nodeType(node)]; + getNodeStyles: function (this: CustomPalette, nodeType: NodeType["type"]) { + return this.nodes[nodeType]; }, getWindowStyles: function (this: CustomPalette, type = WindowKind.default) { switch (type) { case WindowKind.compareVersions: case WindowKind.calculateCounts: return this.windows.compareVersions; + case WindowKind.editProperties: + return this.windows.editProperties; case WindowKind.customAction: return this.windows.customAction; default: diff --git a/designer/client/src/containers/theme/styles.ts b/designer/client/src/containers/theme/styles.ts index 4f3d9c83515..05040f030a0 100644 --- a/designer/client/src/containers/theme/styles.ts +++ b/designer/client/src/containers/theme/styles.ts @@ -236,7 +236,7 @@ export const globalStyles = (theme: Theme) => ({ backgroundColor: theme.palette.action.hover, }, }, - [`.${nodeInput}[readonly], .${nodeInput}[type='checkbox'][readonly]:after, .${nodeInput}[type='radio'][readonly]:after, .${rowAceEditor}.read-only, .${rowAceEditor} .read-only .ace_scroller`]: + [`.${nodeInput}[readonly], .${nodeInput}[type='checkbox'][readonly]:after, .${nodeInput}[type='radio'][readonly]:after, .${rowAceEditor}.read-only, .${rowAceEditor} .read-only .ace_scroller, .${rowAceEditor}.read-only .ace_content`]: { backgroundColor: `${theme.palette.action.disabledBackground} !important`, color: `${theme.palette.action.disabled} !important`, diff --git a/designer/client/src/reducers/graph/reducer.ts b/designer/client/src/reducers/graph/reducer.ts index 0e9e1de8aa5..361a971f59e 100644 --- a/designer/client/src/reducers/graph/reducer.ts +++ b/designer/client/src/reducers/graph/reducer.ts @@ -143,6 +143,16 @@ const graphReducer: Reducer = (state = emptyGraphState, action) => { }, }; } + case "EDIT_PROPERTIES": { + return { + ...state, + scenario: { + ...state.scenario, + scenarioGraph: { ...action.scenarioGraphAfterChange }, + validationResult: updateValidationResult(state, action), + }, + }; + } case "PROCESS_RENAME": { return { ...state, diff --git a/designer/client/src/reducers/graph/utils.fixtures.ts b/designer/client/src/reducers/graph/utils.fixtures.ts index f7b2f01c367..fa320120b80 100644 --- a/designer/client/src/reducers/graph/utils.fixtures.ts +++ b/designer/client/src/reducers/graph/utils.fixtures.ts @@ -22,7 +22,7 @@ export const state: GraphState = { labels: [], scenarioGraph: { properties: { - type: "Properties", + name: "Properties", additionalFields: { description: null, properties: { diff --git a/designer/client/src/reducers/graph/utils.ts b/designer/client/src/reducers/graph/utils.ts index ba5f7cb9ea8..336a5ca3b8a 100644 --- a/designer/client/src/reducers/graph/utils.ts +++ b/designer/client/src/reducers/graph/utils.ts @@ -135,7 +135,7 @@ export function enrichNodeWithProcessDependentData( ): NodeType { const node = cloneDeep(originalNode); - switch (NodeUtils.nodeType(node)) { + switch (node.type) { case "Join": { const parameters = ProcessUtils.extractComponentDefinition(node, processDefinitionData.components)?.parameters; const declaredBranchParameters = parameters?.filter((p) => p.branchParam) || []; diff --git a/designer/client/src/types/node.ts b/designer/client/src/types/node.ts index 6c50031d1f7..c021f21c87e 100644 --- a/designer/client/src/types/node.ts +++ b/designer/client/src/types/node.ts @@ -1,7 +1,7 @@ import { ProcessAdditionalFields, ReturnedType } from "./scenarioGraph"; import { FragmentInputParameter } from "../components/graph/node-modal/fragment-input-definition/item"; -type Type = "Properties" | "FragmentInput" | string; +type Type = "FragmentInput" | string; export type LayoutData = { x: number; y: number }; @@ -12,7 +12,6 @@ export interface BranchParams { export type BranchParametersTemplate = $TodoType; -//FIXME: something wrong here, process and node mixed? export type NodeType = { id: string; type: Type; @@ -39,6 +38,7 @@ export type NodeType = { parameters?: Parameter[]; }; nodeType?: string; + //TODO: Remove me and add correct properties [key: string]: any; }; @@ -60,12 +60,10 @@ export interface Expression { } export type PropertiesType = { - // FE applies fake id as name, but it's not send by/to BE - id?: string; - type: "Properties"; + name: string; additionalFields: ProcessAdditionalFields; }; export type NodeId = NodeType["id"]; -export type UINodeType = NodeType | PropertiesType; +export type NodeOrPropertiesType = NodeType | PropertiesType; diff --git a/designer/client/src/types/scenarioGraph.ts b/designer/client/src/types/scenarioGraph.ts index 41d769fbc1a..6c380451c68 100644 --- a/designer/client/src/types/scenarioGraph.ts +++ b/designer/client/src/types/scenarioGraph.ts @@ -3,7 +3,7 @@ import { TypingResult, UIParameter } from "./definition"; import { Edge, EdgeType } from "./edge"; import { NodeType, PropertiesType } from "./node"; import { ComponentGroup } from "./component"; -import { ScenarioPropertyConfig } from "../components/graph/node-modal/ScenarioProperty"; +import { ScenarioPropertyConfig } from "../components/properties/ScenarioProperty"; export type ScenarioGraphWithName = { processName: string; diff --git a/designer/client/src/windowManager/ContentGetter.tsx b/designer/client/src/windowManager/ContentGetter.tsx index 9c7d2528af2..05ccc81dd31 100644 --- a/designer/client/src/windowManager/ContentGetter.tsx +++ b/designer/client/src/windowManager/ContentGetter.tsx @@ -57,6 +57,10 @@ const ModifyActivityCommentDialog = loadable(() => import("../components/modals/ fallback: , }); +const PropertiesDialog = loadable(() => import("../components/modals/PropertiesDialog"), { + fallback: , +}); + const contentGetter: React.FC> = (props) => { switch (props.data.kind) { case WindowKind.addFragment: @@ -101,6 +105,8 @@ const contentGetter: React.FC> = (props) => { return ; case WindowKind.addAttachment: return ; + case WindowKind.editProperties: + return ; default: return ( diff --git a/designer/client/src/windowManager/WindowKind.tsx b/designer/client/src/windowManager/WindowKind.tsx index 5e19a857daf..984fcfd2fb9 100644 --- a/designer/client/src/windowManager/WindowKind.tsx +++ b/designer/client/src/windowManager/WindowKind.tsx @@ -21,4 +21,5 @@ export enum WindowKind { addComment, modifyActivityComment, addAttachment, + editProperties, } diff --git a/designer/client/src/windowManager/useWindows.ts b/designer/client/src/windowManager/useWindows.ts index 7916fa3de1f..7a1c3d11768 100644 --- a/designer/client/src/windowManager/useWindows.ts +++ b/designer/client/src/windowManager/useWindows.ts @@ -8,26 +8,6 @@ import { Scenario } from "../components/Process/types"; import { NodeType } from "../types"; import { WindowKind } from "./WindowKind"; -export const NodeViewMode = { - edit: false, - readonly: true, - descriptionView: "description", - descriptionEdit: "descriptionEdit", -} as const; -export type NodeViewMode = (typeof NodeViewMode)[keyof typeof NodeViewMode]; - -function mapModeToKind(mode: NodeViewMode): WindowKind { - switch (mode) { - case NodeViewMode.readonly: - return WindowKind.viewNode; - case NodeViewMode.descriptionView: - return WindowKind.viewDescription; - case NodeViewMode.descriptionEdit: - return WindowKind.editDescription; - } - return WindowKind.editNode; -} - const useRemoveFocusOnEscKey = (isWindowOpen: boolean) => { useEffect(() => { if (!isWindowOpen) { @@ -67,15 +47,14 @@ export function useWindows(parent?: WindowId) { ); const openNodeWindow = useCallback( - (node: NodeType, scenario: Scenario, viewMode: NodeViewMode = false, layoutData?: WindowType["layoutData"]) => { + (node: NodeType, scenario: Scenario, readonly?: boolean) => { return open({ id: node.id, title: node.id, isResizable: true, - kind: mapModeToKind(viewMode), + kind: readonly ? WindowKind.viewNode : WindowKind.editNode, meta: { node, scenario }, shouldCloseOnEsc: false, - layoutData, }); }, [open], diff --git a/designer/client/test/CommentContent-test.tsx b/designer/client/test/CommentContent-test.tsx index 601c04511f3..63a177d058c 100644 --- a/designer/client/test/CommentContent-test.tsx +++ b/designer/client/test/CommentContent-test.tsx @@ -1,7 +1,6 @@ import CommentContent from "../src/components/comment/CommentContent"; import React from "react"; import { render } from "@testing-library/react"; -import { describe, expect } from "@jest/globals"; import { NuThemeProvider } from "../src/containers/theme/nuThemeProvider"; describe("CommentContent#newContent", () => { diff --git a/designer/client/test/CountsRangesButtons-test.tsx b/designer/client/test/CountsRangesButtons-test.tsx index 97701163e75..c6f68b7a4c4 100644 --- a/designer/client/test/CountsRangesButtons-test.tsx +++ b/designer/client/test/CountsRangesButtons-test.tsx @@ -2,7 +2,7 @@ import moment from "moment"; import React from "react"; import { CountsRangesButtons } from "../src/components/modals/CalculateCounts/CountsRangesButtons"; import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, jest } from "@jest/globals"; +import { jest } from "@jest/globals"; import { NuThemeProvider } from "../src/containers/theme/nuThemeProvider"; jest.mock("react-i18next", () => ({ diff --git a/designer/client/test/PlainStyleLink-test.tsx b/designer/client/test/PlainStyleLink-test.tsx index fe67307f1a8..1d107383721 100644 --- a/designer/client/test/PlainStyleLink-test.tsx +++ b/designer/client/test/PlainStyleLink-test.tsx @@ -2,7 +2,6 @@ import React from "react"; import { MemoryRouter } from "react-router"; import { isExternalUrl, PlainStyleLink } from "../src/containers/plainStyleLink"; import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "@jest/globals"; const Link = (props) => ( @@ -46,8 +45,6 @@ describe("PlainStyleLink", () => { ])("should support %s", (_, to, expected) => { render(); - //TODO: Fix Jest types we need to import expect, but the types are not extended by @testing-library/jest-dom - // @ts-ignore expect(screen.getByRole("link")).toHaveAttribute("href", expected); }); }); diff --git a/designer/client/test/Process/FrgamentInputDefinition/FixedValuesGroup-test.tsx b/designer/client/test/Process/FrgamentInputDefinition/FixedValuesGroup-test.tsx index df1d642f818..d3cde956175 100644 --- a/designer/client/test/Process/FrgamentInputDefinition/FixedValuesGroup-test.tsx +++ b/designer/client/test/Process/FrgamentInputDefinition/FixedValuesGroup-test.tsx @@ -1,7 +1,7 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, jest } from "@jest/globals"; +import { jest } from "@jest/globals"; import { NuThemeProvider } from "../../../src/containers/theme/nuThemeProvider"; import { FixedValuesGroup } from "../../../src/components/graph/node-modal/fragment-input-definition/settings/variants/fields/FixedValuesGroup"; import { FixedValuesType } from "../../../src/components/graph/node-modal/fragment-input-definition/item"; diff --git a/designer/client/test/Process/FrgamentInputDefinition/Item-test.tsx b/designer/client/test/Process/FrgamentInputDefinition/Item-test.tsx index eeca72398b5..061ddba9f95 100644 --- a/designer/client/test/Process/FrgamentInputDefinition/Item-test.tsx +++ b/designer/client/test/Process/FrgamentInputDefinition/Item-test.tsx @@ -2,7 +2,7 @@ import { Item } from "../../../src/components/graph/node-modal/fragment-input-de import React from "react"; import { NodeRowFieldsProvider } from "../../../src/components/graph/node-modal/node-row-fields-provider"; import { NuThemeProvider } from "../../../src/containers/theme/nuThemeProvider"; -import { describe, expect, it, jest } from "@jest/globals"; +import { jest } from "@jest/globals"; import { fireEvent, render, screen } from "@testing-library/react"; import { ReturnedType } from "../../../src/types"; diff --git a/designer/client/test/Process/ProcessStateIcon-test.tsx b/designer/client/test/Process/ProcessStateIcon-test.tsx index 57f6c0340e9..7c744be1f91 100644 --- a/designer/client/test/Process/ProcessStateIcon-test.tsx +++ b/designer/client/test/Process/ProcessStateIcon-test.tsx @@ -1,6 +1,6 @@ import React from "react"; import ProcessStateIcon from "../../src/components/Process/ProcessStateIcon"; -import { describe, expect, it, jest } from "@jest/globals"; +import { jest } from "@jest/globals"; import { render, waitFor } from "@testing-library/react"; const processState = { diff --git a/designer/client/test/SpelQuotesUtils-test.tsx b/designer/client/test/SpelQuotesUtils-test.tsx index 0adbddd0a80..42fdc62b85b 100644 --- a/designer/client/test/SpelQuotesUtils-test.tsx +++ b/designer/client/test/SpelQuotesUtils-test.tsx @@ -1,6 +1,6 @@ import * as SpelQuotesUtils from "../src/components/graph/node-modal/editors/expression/SpelQuotesUtils"; import { isQuoted, QuotationMark } from "../src/components/graph/node-modal/editors/expression/SpelQuotesUtils"; -import { describe, expect, jest } from "@jest/globals"; +import { jest } from "@jest/globals"; import { stringSpelFormatter } from "../src/components/graph/node-modal/editors/expression/Formatter"; const text = `a'b'c"d"e'f'g`; diff --git a/designer/client/test/useAnonymousStatistics.test.tsx b/designer/client/test/useAnonymousStatistics.test.tsx index 27eb4c51496..3c877b8c0a2 100644 --- a/designer/client/test/useAnonymousStatistics.test.tsx +++ b/designer/client/test/useAnonymousStatistics.test.tsx @@ -1,6 +1,6 @@ import { renderHook, waitFor } from "@testing-library/react"; import { useSelector } from "react-redux"; -import { describe, expect, jest } from "@jest/globals"; +import { jest } from "@jest/globals"; import { useAnonymousStatistics } from "../src/containers/useAnonymousStatistics"; import httpService from "../src/http/HttpService"; import { AxiosResponse } from "axios"; From 14a9a7511b83b9755d205c4e36f2652e417cf98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82abek?= Date: Wed, 20 Nov 2024 12:30:42 +0100 Subject: [PATCH 2/2] [NU-1800] Add TemplateEvaluationResult to evaluate SpEL expression parts in LazyParameter (#7162) Add TemplateEvaluationResult to evaluate SpEL expression parts in LazyParameter --------- Co-authored-by: Arek Burdach --- .../engine/api/TemplateEvaluationResult.scala | 15 +++ .../sql/service/DatabaseQueryEnricher.scala | 14 +-- .../DatabaseQueryEnricherValidationTest.scala | 15 ++- .../ui/definition/DefinitionsService.scala | 8 +- docs/Changelog.md | 2 + docs/MigrationGuide.md | 3 + .../flink/SpelTemplateLazyParameterTest.scala | 118 ++++++++++++++++++ .../management/sample/LoggingService.scala | 6 +- .../management/sample/source/SqlSource.scala | 4 +- ...inkUniversalSchemaBasedSerdeProvider.scala | 7 +- .../api/process/ClassExtractionSettings.scala | 5 +- .../methodbased/MethodDefinition.scala | 2 +- .../engine/spel/SpelExpression.scala | 26 +++- .../engine/spel/SpelExpressionValidator.scala | 9 +- .../nussknacker/engine/InterpreterSpec.scala | 58 ++++++++- .../engine/spel/SpelExpressionSpec.scala | 17 ++- .../SpelTemplatePartsService.scala | 75 +++++++++++ 17 files changed, 349 insertions(+), 35 deletions(-) create mode 100644 components-api/src/main/scala/pl/touk/nussknacker/engine/api/TemplateEvaluationResult.scala create mode 100644 engine/flink/components/base-tests/src/test/scala/pl/touk/nussknacker/engine/flink/SpelTemplateLazyParameterTest.scala create mode 100644 scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/testcomponents/SpelTemplatePartsService.scala diff --git a/components-api/src/main/scala/pl/touk/nussknacker/engine/api/TemplateEvaluationResult.scala b/components-api/src/main/scala/pl/touk/nussknacker/engine/api/TemplateEvaluationResult.scala new file mode 100644 index 00000000000..0353b003bea --- /dev/null +++ b/components-api/src/main/scala/pl/touk/nussknacker/engine/api/TemplateEvaluationResult.scala @@ -0,0 +1,15 @@ +package pl.touk.nussknacker.engine.api + +case class TemplateEvaluationResult(renderedParts: List[TemplateRenderedPart]) { + def renderedTemplate: String = renderedParts.map(_.value).mkString("") +} + +sealed trait TemplateRenderedPart { + def value: String +} + +object TemplateRenderedPart { + case class RenderedLiteral(value: String) extends TemplateRenderedPart + + case class RenderedSubExpression(value: String) extends TemplateRenderedPart +} diff --git a/components/sql/src/main/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricher.scala b/components/sql/src/main/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricher.scala index 9b4f525235e..457ff3a92ae 100644 --- a/components/sql/src/main/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricher.scala +++ b/components/sql/src/main/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricher.scala @@ -41,10 +41,7 @@ object DatabaseQueryEnricher { final val queryParamName: ParameterName = ParameterName("Query") - final val queryParamDeclaration = - ParameterDeclaration - .mandatory[String](queryParamName) - .withCreator(modify = _.copy(editor = Some(SqlParameterEditor))) + final val queryParam = Parameter[String](queryParamName).copy(editor = Some(SqlParameterEditor)) final val resultStrategyParamName: ParameterName = ParameterName("Result strategy") @@ -132,7 +129,7 @@ class DatabaseQueryEnricher(val dbPoolConfig: DBPoolConfig, val dbMetaDataProvid ): ContextTransformationDefinition = { case TransformationStep(Nil, _) => NextParameters(parameters = resultStrategyParamDeclaration.createParameter() :: - queryParamDeclaration.createParameter() :: + queryParam :: cacheTTLParamDeclaration.createParameter() :: Nil ) } @@ -142,14 +139,15 @@ class DatabaseQueryEnricher(val dbPoolConfig: DBPoolConfig, val dbMetaDataProvid ): ContextTransformationDefinition = { case TransformationStep( (`resultStrategyParamName`, DefinedEagerParameter(strategyName: String, _)) :: - (`queryParamName`, DefinedEagerParameter(query: String, _)) :: + (`queryParamName`, DefinedEagerParameter(query: TemplateEvaluationResult, _)) :: (`cacheTTLParamName`, _) :: Nil, None ) => - if (query.isEmpty) { + val renderedQuery = query.renderedTemplate + if (renderedQuery.isEmpty) { FinalResults(context, errors = CustomNodeError("Query is missing", Some(queryParamName)) :: Nil, state = None) } else { - parseQuery(context, dependencies, strategyName, query) + parseQuery(context, dependencies, strategyName, renderedQuery) } } diff --git a/components/sql/src/test/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricherValidationTest.scala b/components/sql/src/test/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricherValidationTest.scala index 1b7c63d4766..62a2873475a 100644 --- a/components/sql/src/test/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricherValidationTest.scala +++ b/components/sql/src/test/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricherValidationTest.scala @@ -1,10 +1,11 @@ package pl.touk.nussknacker.sql.service +import pl.touk.nussknacker.engine.api.TemplateRenderedPart.RenderedLiteral import pl.touk.nussknacker.engine.api.context.ProcessCompilationError.CustomNodeError import pl.touk.nussknacker.engine.api.context.transformation.{DefinedEagerParameter, OutputVariableNameValue} import pl.touk.nussknacker.engine.api.context.{OutputVar, ValidationContext} import pl.touk.nussknacker.engine.api.typed.typing.{Typed, Unknown} -import pl.touk.nussknacker.engine.api.NodeId +import pl.touk.nussknacker.engine.api.{NodeId, TemplateEvaluationResult} import pl.touk.nussknacker.sql.db.query.{ResultSetStrategy, SingleResultStrategy} import pl.touk.nussknacker.sql.db.schema.MetaDataProviderFactory import pl.touk.nussknacker.sql.utils.BaseHsqlQueryEnricherTest @@ -32,8 +33,10 @@ class DatabaseQueryEnricherValidationTest extends BaseHsqlQueryEnricherTest { service.TransformationStep( List( DatabaseQueryEnricher.resultStrategyParamName -> eagerValueParameter(SingleResultStrategy.name), - DatabaseQueryEnricher.queryParamName -> eagerValueParameter("select from"), - DatabaseQueryEnricher.cacheTTLParamName -> eagerValueParameter(Duration.ofMinutes(1)), + DatabaseQueryEnricher.queryParamName -> eagerValueParameter( + TemplateEvaluationResult(List(RenderedLiteral("select from"))) + ), + DatabaseQueryEnricher.cacheTTLParamName -> eagerValueParameter(Duration.ofMinutes(1)), ), None ) @@ -62,8 +65,10 @@ class DatabaseQueryEnricherValidationTest extends BaseHsqlQueryEnricherTest { service.TransformationStep( List( DatabaseQueryEnricher.resultStrategyParamName -> eagerValueParameter(ResultSetStrategy.name), - DatabaseQueryEnricher.queryParamName -> eagerValueParameter("select * from persons"), - DatabaseQueryEnricher.cacheTTLParamName -> eagerValueParameter(Duration.ofMinutes(1)), + DatabaseQueryEnricher.queryParamName -> eagerValueParameter( + TemplateEvaluationResult(List(RenderedLiteral("select * from persons"))) + ), + DatabaseQueryEnricher.cacheTTLParamName -> eagerValueParameter(Duration.ofMinutes(1)), ), None ) diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/definition/DefinitionsService.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/definition/DefinitionsService.scala index d9cd34c4017..56c0eb77e07 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/definition/DefinitionsService.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/definition/DefinitionsService.scala @@ -9,6 +9,8 @@ import pl.touk.nussknacker.engine.definition.component.methodbased.MethodBasedCo import pl.touk.nussknacker.engine.definition.component.{ComponentStaticDefinition, FragmentSpecificData} import pl.touk.nussknacker.engine.util.Implicits.RichScalaMap import pl.touk.nussknacker.engine.ModelData +import pl.touk.nussknacker.engine.api.TemplateEvaluationResult +import pl.touk.nussknacker.engine.api.typed.typing.{Typed, TypingResult} import pl.touk.nussknacker.restmodel.definition._ import pl.touk.nussknacker.ui.definition.DefinitionsService.{ ComponentUiConfigMode, @@ -162,7 +164,7 @@ object DefinitionsService { def createUIParameter(parameter: Parameter): UIParameter = { UIParameter( name = parameter.name.value, - typ = parameter.typ, + typ = toUIType(parameter.typ), editor = parameter.finalEditor, defaultValue = parameter.finalDefaultValue, additionalVariables = parameter.additionalVariables.mapValuesNow(_.typingResult), @@ -174,6 +176,10 @@ object DefinitionsService { ) } + private def toUIType(typingResult: TypingResult): TypingResult = { + if (typingResult == Typed[TemplateEvaluationResult]) Typed[String] else typingResult + } + def createUIScenarioPropertyConfig(config: ScenarioPropertyConfig): UiScenarioPropertyConfig = { val editor = UiScenarioPropertyEditorDeterminer.determine(config) UiScenarioPropertyConfig(config.defaultValue, editor, config.label, config.hintText) diff --git a/docs/Changelog.md b/docs/Changelog.md index 9e45ab6c331..dd7f1d53d38 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -13,6 +13,8 @@ * [#7145](https://github.com/TouK/nussknacker/pull/7145) Lift TypingResult information for dictionaries * [#7116](https://github.com/TouK/nussknacker/pull/7116) Improve missing Flink Kafka Source / Sink TypeInformation * [#7123](https://github.com/TouK/nussknacker/pull/7123) Fix deployments for scenarios with dict editors after model reload +* [#7162](https://github.com/TouK/nussknacker/pull/7162) Component API enhancement: ability to access information about + expression parts used in SpEL template ## 1.18 diff --git a/docs/MigrationGuide.md b/docs/MigrationGuide.md index 308a2d12f8d..8b270d52433 100644 --- a/docs/MigrationGuide.md +++ b/docs/MigrationGuide.md @@ -52,6 +52,9 @@ To see the biggest differences please consult the [changelog](Changelog.md). * [#6988](https://github.com/TouK/nussknacker/pull/6988) Removed unused API classes: `MultiMap`, `TimestampedEvictableStateFunction`. `MultiMap` was incorrectly handled by Flink's default Kryo serializer, so if you want to copy it to your code you should write and register a proper serializer. +* [#7162](https://github.com/TouK/nussknacker/pull/7162) When component declares that requires parameter with either `SpelTemplateParameterEditor` + or `SqlParameterEditor` editor, in the runtime, for the expression evaluation result, will be used the new `TemplateEvaluationResult` + class instead of `String` class. To access the previous `String` use `TemplateEvaluationResult.renderedTemplate` method. ### REST API changes diff --git a/engine/flink/components/base-tests/src/test/scala/pl/touk/nussknacker/engine/flink/SpelTemplateLazyParameterTest.scala b/engine/flink/components/base-tests/src/test/scala/pl/touk/nussknacker/engine/flink/SpelTemplateLazyParameterTest.scala new file mode 100644 index 00000000000..c2f3d218673 --- /dev/null +++ b/engine/flink/components/base-tests/src/test/scala/pl/touk/nussknacker/engine/flink/SpelTemplateLazyParameterTest.scala @@ -0,0 +1,118 @@ +package pl.touk.nussknacker.engine.flink + +import com.typesafe.config.ConfigFactory +import org.apache.flink.api.common.functions.FlatMapFunction +import org.apache.flink.api.connector.source.Boundedness +import org.apache.flink.streaming.api.datastream.DataStream +import org.apache.flink.util.Collector +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.touk.nussknacker.engine.api.TemplateRenderedPart.{RenderedLiteral, RenderedSubExpression} +import pl.touk.nussknacker.engine.api._ +import pl.touk.nussknacker.engine.api.component.{BoundedStreamComponent, ComponentDefinition} +import pl.touk.nussknacker.engine.api.context.ValidationContext +import pl.touk.nussknacker.engine.api.context.transformation.{DefinedLazyParameter, NodeDependencyValue, SingleInputDynamicComponent} +import pl.touk.nussknacker.engine.api.definition.{NodeDependency, OutputVariableNameDependency, Parameter, SpelTemplateParameterEditor} +import pl.touk.nussknacker.engine.api.parameter.ParameterName +import pl.touk.nussknacker.engine.api.typed.typing.Typed +import pl.touk.nussknacker.engine.build.ScenarioBuilder +import pl.touk.nussknacker.engine.flink.api.process.{AbstractOneParamLazyParameterFunction, FlinkCustomNodeContext, FlinkCustomStreamTransformation} +import pl.touk.nussknacker.engine.flink.test.FlinkSpec +import pl.touk.nussknacker.engine.flink.util.test.FlinkTestScenarioRunner._ +import pl.touk.nussknacker.engine.graph.expression.Expression +import pl.touk.nussknacker.engine.process.FlinkJobConfig.ExecutionMode +import pl.touk.nussknacker.engine.spel.SpelExtension._ +import pl.touk.nussknacker.engine.util.test.TestScenarioRunner +import pl.touk.nussknacker.test.ValidatedValuesDetailedMessage + +class SpelTemplateLazyParameterTest extends AnyFunSuite with FlinkSpec with Matchers with ValidatedValuesDetailedMessage { + + private lazy val runner = TestScenarioRunner + .flinkBased(ConfigFactory.empty(), flinkMiniCluster) + .withExecutionMode(ExecutionMode.Batch) + .withExtraComponents( + List(ComponentDefinition("spelTemplatePartsCustomTransformer", SpelTemplatePartsCustomTransformer)) + ) + .build() + + test("flink custom transformer using spel template rendered parts") { + val scenario = ScenarioBuilder + .streaming("test") + .source("source", TestScenarioRunner.testDataSource) + .customNode( + "custom", + "output", + "spelTemplatePartsCustomTransformer", + "template" -> Expression.spelTemplate(s"Hello#{#input}") + ) + .emptySink("sink", TestScenarioRunner.testResultSink, "value" -> "#output".spel) + + val result = runner.runWithData(scenario, List(1, 2, 3), Boundedness.BOUNDED) + result.validValue.errors shouldBe empty + result.validValue.successes shouldBe List( + "[Hello]-literal[1]-subexpression", + "[Hello]-literal[2]-subexpression", + "[Hello]-literal[3]-subexpression" + ) + } + +} + +object SpelTemplatePartsCustomTransformer + extends CustomStreamTransformer + with SingleInputDynamicComponent[FlinkCustomStreamTransformation] + with BoundedStreamComponent { + + private val spelTemplateParameterName = ParameterName("template") + + private val spelTemplateParameter = Parameter + .optional[String](spelTemplateParameterName) + .copy( + isLazyParameter = true, + editor = Some(SpelTemplateParameterEditor) + ) + + override type State = Unit + + override def contextTransformation(context: ValidationContext, dependencies: List[NodeDependencyValue])( + implicit nodeId: NodeId + ): SpelTemplatePartsCustomTransformer.ContextTransformationDefinition = { + case TransformationStep(Nil, _) => NextParameters(List(spelTemplateParameter)) + case TransformationStep((`spelTemplateParameterName`, DefinedLazyParameter(_)) :: Nil, _) => + val outName = OutputVariableNameDependency.extract(dependencies) + FinalResults(context.withVariableUnsafe(outName, Typed[String]), List.empty) + } + + override def nodeDependencies: List[NodeDependency] = List(OutputVariableNameDependency) + + override def implementation( + params: Params, + dependencies: List[NodeDependencyValue], + finalState: Option[Unit] + ): FlinkCustomStreamTransformation = { + val templateLazyParam: LazyParameter[TemplateEvaluationResult] = + params.extractUnsafe[LazyParameter[TemplateEvaluationResult]](spelTemplateParameterName) + FlinkCustomStreamTransformation { + (dataStream: DataStream[Context], flinkCustomNodeContext: FlinkCustomNodeContext) => + dataStream.flatMap( + new AbstractOneParamLazyParameterFunction[TemplateEvaluationResult]( + templateLazyParam, + flinkCustomNodeContext.lazyParameterHelper + ) with FlatMapFunction[Context, ValueWithContext[String]] { + override def flatMap(value: Context, out: Collector[ValueWithContext[String]]): Unit = { + collectHandlingErrors(value, out) { + val templateResult = evaluateParameter(value) + val result = templateResult.renderedParts.map { + case RenderedLiteral(value) => s"[$value]-literal" + case RenderedSubExpression(value) => s"[$value]-subexpression" + }.mkString + ValueWithContext(result, value) + } + } + }, + flinkCustomNodeContext.valueWithContextInfo.forClass[String] + ).asInstanceOf[DataStream[ValueWithContext[AnyRef]]] + } + } + +} diff --git a/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/LoggingService.scala b/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/LoggingService.scala index 1ae07feef83..1794b005f20 100644 --- a/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/LoggingService.scala +++ b/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/LoggingService.scala @@ -18,7 +18,9 @@ object LoggingService extends EagerService { def prepare( @ParamName("logger") @Nullable loggerName: String, @ParamName("level") @DefaultValue("T(org.slf4j.event.Level).DEBUG") level: Level, - @ParamName("message") @SimpleEditor(`type` = SimpleEditorType.SPEL_TEMPLATE_EDITOR) message: LazyParameter[String] + @ParamName("message") @SimpleEditor(`type` = SimpleEditorType.SPEL_TEMPLATE_EDITOR) message: LazyParameter[ + TemplateEvaluationResult + ] )(implicit metaData: MetaData, nodeId: NodeId): ServiceInvoker = new ServiceInvoker { @@ -31,7 +33,7 @@ object LoggingService extends EagerService { collector: ServiceInvocationCollector, componentUseCase: ComponentUseCase ): Future[Any] = { - val msg = message.evaluate(context) + val msg = message.evaluate(context).renderedTemplate level match { case Level.TRACE => logger.trace(msg) case Level.DEBUG => logger.debug(msg) diff --git a/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/source/SqlSource.scala b/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/source/SqlSource.scala index f353c1427e0..fdccb9b7a4a 100644 --- a/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/source/SqlSource.scala +++ b/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/source/SqlSource.scala @@ -4,14 +4,14 @@ import pl.touk.nussknacker.engine.api.component.UnboundedStreamComponent import pl.touk.nussknacker.engine.api.editor.{SimpleEditor, SimpleEditorType} import pl.touk.nussknacker.engine.api.process.SourceFactory import pl.touk.nussknacker.engine.api.typed.typing.Unknown -import pl.touk.nussknacker.engine.api.{MethodToInvoke, ParamName} +import pl.touk.nussknacker.engine.api.{MethodToInvoke, ParamName, TemplateEvaluationResult} import pl.touk.nussknacker.engine.flink.util.source.CollectionSource //It's only for test FE sql editor object SqlSource extends SourceFactory with UnboundedStreamComponent { @MethodToInvoke - def source(@ParamName("sql") @SimpleEditor(`type` = SimpleEditorType.SQL_EDITOR) sql: String) = + def source(@ParamName("sql") @SimpleEditor(`type` = SimpleEditorType.SQL_EDITOR) sql: TemplateEvaluationResult) = new CollectionSource[Any](List.empty, None, Unknown) } diff --git a/engine/flink/schemed-kafka-components-utils/src/main/scala/pl/touk/nussknacker/engine/schemedkafka/FlinkUniversalSchemaBasedSerdeProvider.scala b/engine/flink/schemed-kafka-components-utils/src/main/scala/pl/touk/nussknacker/engine/schemedkafka/FlinkUniversalSchemaBasedSerdeProvider.scala index 4f14299516e..4a56c525bbd 100644 --- a/engine/flink/schemed-kafka-components-utils/src/main/scala/pl/touk/nussknacker/engine/schemedkafka/FlinkUniversalSchemaBasedSerdeProvider.scala +++ b/engine/flink/schemed-kafka-components-utils/src/main/scala/pl/touk/nussknacker/engine/schemedkafka/FlinkUniversalSchemaBasedSerdeProvider.scala @@ -2,7 +2,12 @@ package pl.touk.nussknacker.engine.schemedkafka import pl.touk.nussknacker.engine.schemedkafka.schemaregistry.serialization.KafkaSchemaRegistryBasedValueSerializationSchemaFactory import pl.touk.nussknacker.engine.schemedkafka.schemaregistry.universal.UniversalSchemaBasedSerdeProvider.createSchemaIdFromMessageExtractor -import pl.touk.nussknacker.engine.schemedkafka.schemaregistry.universal.{UniversalKafkaDeserializerFactory, UniversalSchemaValidator, UniversalSerializerFactory, UniversalToJsonFormatterFactory} +import pl.touk.nussknacker.engine.schemedkafka.schemaregistry.universal.{ + UniversalKafkaDeserializerFactory, + UniversalSchemaValidator, + UniversalSerializerFactory, + UniversalToJsonFormatterFactory +} import pl.touk.nussknacker.engine.schemedkafka.schemaregistry.{SchemaBasedSerdeProvider, SchemaRegistryClientFactory} import pl.touk.nussknacker.engine.schemedkafka.source.flink.FlinkKafkaSchemaRegistryBasedKeyValueDeserializationSchemaFactory diff --git a/extensions-api/src/main/scala/pl/touk/nussknacker/engine/api/process/ClassExtractionSettings.scala b/extensions-api/src/main/scala/pl/touk/nussknacker/engine/api/process/ClassExtractionSettings.scala index bb03fb10cfe..7e9da4b0968 100644 --- a/extensions-api/src/main/scala/pl/touk/nussknacker/engine/api/process/ClassExtractionSettings.scala +++ b/extensions-api/src/main/scala/pl/touk/nussknacker/engine/api/process/ClassExtractionSettings.scala @@ -5,7 +5,7 @@ import io.circe.{Decoder, Encoder} import pl.touk.nussknacker.engine.api.definition.ParameterEditor import pl.touk.nussknacker.engine.api.typed.supertype.ReturningSingleClassPromotionStrategy import pl.touk.nussknacker.engine.api.typed.typing.Typed -import pl.touk.nussknacker.engine.api.{Hidden, HideToString} +import pl.touk.nussknacker.engine.api.{Hidden, HideToString, TemplateEvaluationResult} import java.lang.reflect.{AccessibleObject, Member, Method} import java.text.NumberFormat @@ -109,8 +109,9 @@ object ClassExtractionSettings { // we want only boxed types ClassPredicate { case cl => cl.isPrimitive }, ExactClassPredicate[ReturningSingleClassPromotionStrategy], - // We use this type only programmable + // We use these types only programmable ClassNamePredicate("pl.touk.nussknacker.engine.spel.SpelExpressionRepr"), + ExactClassPredicate[TemplateEvaluationResult], ) lazy val ExcludedCollectionFunctionalClasses: List[ClassPredicate] = List( diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/component/methodbased/MethodDefinition.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/component/methodbased/MethodDefinition.scala index f366f2b2d12..1d9657ecc44 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/component/methodbased/MethodDefinition.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/component/methodbased/MethodDefinition.scala @@ -65,7 +65,7 @@ class OrderedDependencies(dependencies: List[NodeDependency]) extends Serializab ): List[Any] = { dependencies.map { case param: Parameter => - values.getOrElse(param.name, throw new IllegalArgumentException(s"Missing parameter: ${param.name}")) + values.getOrElse(param.name, throw new IllegalArgumentException(s"Missing parameter: ${param.name.value}")) case OutputVariableNameDependency => outputVariableNameOpt.getOrElse(throw MissingOutputVariableException) case TypedNodeDependency(clazz) => diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpression.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpression.scala index a521545c97f..07b67ff495f 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpression.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpression.scala @@ -13,13 +13,14 @@ import org.springframework.expression.spel.{ SpelParserConfiguration, standard } -import pl.touk.nussknacker.engine.api.Context +import pl.touk.nussknacker.engine.api.TemplateRenderedPart.{RenderedLiteral, RenderedSubExpression} import pl.touk.nussknacker.engine.api.context.ValidationContext import pl.touk.nussknacker.engine.api.dict.DictRegistry import pl.touk.nussknacker.engine.api.exception.NonTransientException import pl.touk.nussknacker.engine.api.generics.ExpressionParseError import pl.touk.nussknacker.engine.api.typed.typing import pl.touk.nussknacker.engine.api.typed.typing.{SingleTypingResult, TypingResult} +import pl.touk.nussknacker.engine.api.{Context, TemplateEvaluationResult, TemplateRenderedPart} import pl.touk.nussknacker.engine.definition.clazz.ClassDefinitionSet import pl.touk.nussknacker.engine.definition.globalvariables.ExpressionConfigDefinition import pl.touk.nussknacker.engine.dict.{KeysDictTyper, LabelsDictTyper} @@ -107,7 +108,28 @@ class SpelExpression( return SpelExpressionRepr(parsed.parsed, ctx, globals, original).asInstanceOf[T] } val evaluationContext = evaluationContextPreparer.prepareEvaluationContext(ctx, globals) - parsed.getValue[T](evaluationContext, expectedClass) + flavour match { + case SpelExpressionParser.Standard => + parsed.getValue[T](evaluationContext, expectedClass) + case SpelExpressionParser.Template => + val parts = renderTemplateExpressionParts(evaluationContext) + TemplateEvaluationResult(parts).asInstanceOf[T] + } + } + + private def renderTemplateExpressionParts(evaluationContext: EvaluationContext): List[TemplateRenderedPart] = { + def renderExpression(expression: Expression): List[TemplateRenderedPart] = expression match { + case literal: LiteralExpression => List(RenderedLiteral(literal.getExpressionString)) + case spelExpr: org.springframework.expression.spel.standard.SpelExpression => + // TODO: Should we use the same trick with re-parsing after ClassCastException as we use in ParsedSpelExpression? + List(RenderedSubExpression(spelExpr.getValue[String](evaluationContext, classOf[String]))) + case composite: CompositeStringExpression => composite.getExpressions.toList.flatMap(renderExpression) + case other => + throw new IllegalArgumentException( + s"Unsupported expression type: ${other.getClass.getName} for a template expression" + ) + } + renderExpression(parsed.parsed) } private def logOnException[A](ctx: Context)(block: => A): A = { diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionValidator.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionValidator.scala index ac49dff185c..1e708e1a636 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionValidator.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionValidator.scala @@ -3,6 +3,7 @@ package pl.touk.nussknacker.engine.spel import cats.data.Validated.{Invalid, Valid} import cats.data.{NonEmptyList, Validated} import org.springframework.expression.Expression +import pl.touk.nussknacker.engine.api.TemplateEvaluationResult import pl.touk.nussknacker.engine.api.context.ValidationContext import pl.touk.nussknacker.engine.api.generics.ExpressionParseError import pl.touk.nussknacker.engine.api.typed.typing.{Typed, TypingResult} @@ -18,9 +19,13 @@ class SpelExpressionValidator(typer: Typer) { val typedExpression = typer.typeExpression(expr, ctx) typedExpression.andThen { collected => collected.finalResult.typingResult match { - case a: TypingResult if a.canBeSubclassOf(expectedType) || expectedType == Typed[SpelExpressionRepr] => + case _ if expectedType == Typed[SpelExpressionRepr] => Valid(collected) - case a: TypingResult => + case a if a == Typed[String] && expectedType == Typed[TemplateEvaluationResult] => + Valid(collected) + case a if a.canBeSubclassOf(expectedType) => + Valid(collected) + case a => Invalid(NonEmptyList.of(ExpressionTypeError(expectedType, a))) } } diff --git a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/InterpreterSpec.scala b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/InterpreterSpec.scala index e5135ece369..b41507da7d5 100644 --- a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/InterpreterSpec.scala +++ b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/InterpreterSpec.scala @@ -6,6 +6,8 @@ import cats.effect.IO import cats.effect.unsafe.IORuntime import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.TableDrivenPropertyChecks.forAll +import org.scalatest.prop.Tables.Table import org.springframework.expression.spel.standard.SpelExpression import pl.touk.nussknacker.engine.InterpreterSpec._ import pl.touk.nussknacker.engine.api._ @@ -33,8 +35,8 @@ import pl.touk.nussknacker.engine.canonicalgraph.canonicalnode.FlatNode import pl.touk.nussknacker.engine.canonicalgraph.{CanonicalProcess, canonicalnode} import pl.touk.nussknacker.engine.compile._ import pl.touk.nussknacker.engine.compiledgraph.part.{CustomNodePart, ProcessPart, SinkPart} +import pl.touk.nussknacker.engine.definition.component.Components import pl.touk.nussknacker.engine.definition.component.Components.ComponentDefinitionExtractionMode -import pl.touk.nussknacker.engine.definition.component.{ComponentDefinitionWithImplementation, Components} import pl.touk.nussknacker.engine.definition.model.{ModelDefinition, ModelDefinitionWithClasses} import pl.touk.nussknacker.engine.dict.SimpleDictRegistry import pl.touk.nussknacker.engine.graph.evaluatedparam.{Parameter => NodeParameter} @@ -47,6 +49,7 @@ import pl.touk.nussknacker.engine.graph.variable.Field import pl.touk.nussknacker.engine.modelconfig.ComponentsUiConfig import pl.touk.nussknacker.engine.resultcollector.ProductionServiceInvocationCollector import pl.touk.nussknacker.engine.spel.SpelExpressionRepr +import pl.touk.nussknacker.engine.testcomponents.SpelTemplatePartsService import pl.touk.nussknacker.engine.testing.ModelDefinitionBuilder import pl.touk.nussknacker.engine.util.service.{ EagerServiceWithStaticParametersAndReturnType, @@ -72,6 +75,7 @@ class InterpreterSpec extends AnyFunSuite with Matchers { ComponentDefinition("spelNodeService", SpelNodeService), ComponentDefinition("withExplicitMethod", WithExplicitDefinitionService), ComponentDefinition("spelTemplateService", ServiceUsingSpelTemplate), + ComponentDefinition("spelTemplatePartsService", SpelTemplatePartsService), ComponentDefinition("optionTypesService", OptionTypesService), ComponentDefinition("optionalTypesService", OptionalTypesService), ComponentDefinition("nullableTypesService", NullableTypesService), @@ -1020,6 +1024,52 @@ class InterpreterSpec extends AnyFunSuite with Matchers { interpretProcess(process, Transaction()) shouldBe "someKey" } + test("service using spel template rendered parts") { + val testCases = Seq( + ( + "subexpression and literal value", + s"Hello#{#input.msisdn}", + Transaction(msisdn = "foo"), + "[Hello]-literal[foo]-subexpression" + ), + ( + "single literal value", + "Hello", + Transaction(msisdn = "foo"), + "[Hello]-literal" + ), + ( + "single function call expression", + "#{#input.msisdn.toString()}", + Transaction(msisdn = "foo"), + "[foo]-subexpression" + ), + ( + "empty value", + "", + Transaction(msisdn = "foo"), + "[]-literal" + ), + ) + for ((description, templateExpression, inputTransaction, expectedOutput) <- testCases) { + withClue(s"Test case: $description") { + val process = ScenarioBuilder + .streaming("test") + .source("start", "transaction-source") + .enricher( + "ex", + "out", + "spelTemplatePartsService", + "template" -> Expression.spelTemplate(templateExpression) + ) + .buildSimpleVariable("result-end", resultVariable, "#out".spel) + .emptySink("end-end", "dummySink") + + interpretProcess(process, inputTransaction) should equal(expectedOutput) + } + } + } + } class ThrowingService extends Service { @@ -1134,8 +1184,10 @@ object InterpreterSpec { object ServiceUsingSpelTemplate extends EagerServiceWithStaticParametersAndReturnType { + private val spelTemplateParameterName = ParameterName("template") + private val spelTemplateParameter = Parameter - .optional[String](ParameterName("template")) + .optional[String](spelTemplateParameterName) .copy(isLazyParameter = true, editor = Some(SpelTemplateParameterEditor)) override def parameters: List[Parameter] = List(spelTemplateParameter) @@ -1149,7 +1201,7 @@ object InterpreterSpec { metaData: MetaData, componentUseCase: ComponentUseCase ): Future[AnyRef] = { - Future.successful(params.head._2.toString) + Future.successful(params(spelTemplateParameterName).asInstanceOf[TemplateEvaluationResult].renderedTemplate) } } diff --git a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala index 6e7331fda8f..77cbdddcb66 100644 --- a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala +++ b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala @@ -26,7 +26,7 @@ import pl.touk.nussknacker.engine.api.process.ExpressionConfig._ import pl.touk.nussknacker.engine.api.typed.TypedMap import pl.touk.nussknacker.engine.api.typed.typing.Typed.typedListWithElementValues import pl.touk.nussknacker.engine.api.typed.typing.{Typed, _} -import pl.touk.nussknacker.engine.api.{Context, Hidden, NodeId, SpelExpressionExcludeList} +import pl.touk.nussknacker.engine.api.{Context, Hidden, NodeId, SpelExpressionExcludeList, TemplateEvaluationResult} import pl.touk.nussknacker.engine.definition.clazz.{ClassDefinitionSet, ClassDefinitionTestUtils, JavaClassWithVarargs} import pl.touk.nussknacker.engine.dict.SimpleDictRegistry import pl.touk.nussknacker.engine.expression.parse.{CompiledExpression, TypedExpression} @@ -81,10 +81,10 @@ class SpelExpressionSpec extends AnyFunSuite with Matchers with ValidatedValuesD private implicit class EvaluateSyncTyped(expression: TypedExpression) { - def evaluateSync[T](ctx: Context = ctx): T = { + def evaluateSync[T](ctx: Context = ctx, skipReturnTypeCheck: Boolean = false): T = { val evaluationResult = expression.expression.evaluate[T](ctx, Map.empty) expression.typingInfo.typingResult match { - case result: SingleTypingResult if evaluationResult != null => + case result: SingleTypingResult if evaluationResult != null && !skipReturnTypeCheck => result.runtimeObjType.klass isAssignableFrom evaluationResult.getClass shouldBe true case _ => } @@ -1088,16 +1088,21 @@ class SpelExpressionSpec extends AnyFunSuite with Matchers with ValidatedValuesD test("evaluates expression with template context") { parse[String]("alamakota #{444}", ctx, flavour = SpelExpressionParser.Template).validExpression - .evaluateSync[String]() shouldBe "alamakota 444" + .evaluateSync[TemplateEvaluationResult](skipReturnTypeCheck = true) + .renderedTemplate shouldBe "alamakota 444" parse[String]( "alamakota #{444 + #obj.value} #{#mapValue.foo}", ctx, flavour = SpelExpressionParser.Template - ).validExpression.evaluateSync[String]() shouldBe "alamakota 446 bar" + ).validExpression + .evaluateSync[TemplateEvaluationResult](skipReturnTypeCheck = true) + .renderedTemplate shouldBe "alamakota 446 bar" } test("evaluates empty template as empty string") { - parse[String]("", ctx, flavour = SpelExpressionParser.Template).validExpression.evaluateSync[String]() shouldBe "" + parse[String]("", ctx, flavour = SpelExpressionParser.Template).validExpression + .evaluateSync[TemplateEvaluationResult](skipReturnTypeCheck = true) + .renderedTemplate shouldBe "" } test("variables with TypeMap type") { diff --git a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/testcomponents/SpelTemplatePartsService.scala b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/testcomponents/SpelTemplatePartsService.scala new file mode 100644 index 00000000000..675889bd025 --- /dev/null +++ b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/testcomponents/SpelTemplatePartsService.scala @@ -0,0 +1,75 @@ +package pl.touk.nussknacker.engine.testcomponents + +import pl.touk.nussknacker.engine.api.TemplateRenderedPart.{RenderedLiteral, RenderedSubExpression} +import pl.touk.nussknacker.engine.api._ +import pl.touk.nussknacker.engine.api.context.transformation.{ + DefinedLazyParameter, + NodeDependencyValue, + SingleInputDynamicComponent +} +import pl.touk.nussknacker.engine.api.context.{OutputVar, ValidationContext} +import pl.touk.nussknacker.engine.api.definition.{ + NodeDependency, + OutputVariableNameDependency, + Parameter, + SpelTemplateParameterEditor +} +import pl.touk.nussknacker.engine.api.parameter.ParameterName +import pl.touk.nussknacker.engine.api.process.ComponentUseCase +import pl.touk.nussknacker.engine.api.test.InvocationCollectors +import pl.touk.nussknacker.engine.api.typed.typing + +import scala.concurrent.{ExecutionContext, Future} + +object SpelTemplatePartsService extends EagerService with SingleInputDynamicComponent[ServiceInvoker] { + + private val spelTemplateParameterName = ParameterName("template") + + private val spelTemplateParameter = Parameter + .optional[String](spelTemplateParameterName) + .copy( + isLazyParameter = true, + editor = Some(SpelTemplateParameterEditor) + ) + + override type State = Any + + override def contextTransformation(context: ValidationContext, dependencies: List[NodeDependencyValue])( + implicit nodeId: NodeId + ): SpelTemplatePartsService.ContextTransformationDefinition = { + case TransformationStep(Nil, _) => NextParameters(List(spelTemplateParameter)) + case TransformationStep((`spelTemplateParameterName`, DefinedLazyParameter(_)) :: Nil, _) => + FinalResults.forValidation(context, List.empty)(validation = + ctx => + ctx.withVariable( + OutputVariableNameDependency.extract(dependencies), + typing.Typed[String], + Some(ParameterName(OutputVar.VariableFieldName)) + ) + ) + } + + override def implementation( + params: Params, + dependencies: List[NodeDependencyValue], + finalState: Option[Any] + ): ServiceInvoker = new ServiceInvoker { + + override def invoke(context: Context)( + implicit ec: ExecutionContext, + collector: InvocationCollectors.ServiceInvocationCollector, + componentUseCase: ComponentUseCase + ): Future[Any] = { + val templateResult = + params.extractOrEvaluateLazyParamUnsafe[TemplateEvaluationResult](spelTemplateParameterName, context) + val result = templateResult.renderedParts.map { + case RenderedLiteral(value) => s"[$value]-literal" + case RenderedSubExpression(value) => s"[$value]-subexpression" + }.mkString + Future.successful(result) + } + + } + + override def nodeDependencies: List[NodeDependency] = List(OutputVariableNameDependency) +}