diff --git a/client/src/config/tests/useFetchInterceptor.test.ts b/client/src/config/tests/useFetchInterceptor.test.ts index 0658b580312..515e0778b1e 100644 --- a/client/src/config/tests/useFetchInterceptor.test.ts +++ b/client/src/config/tests/useFetchInterceptor.test.ts @@ -1,3 +1,4 @@ +import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore'; import {act, renderHook} from '@testing-library/react'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; @@ -669,4 +670,45 @@ describe('useFetchInterceptor', () => { expect(innerFetch).toHaveBeenCalledTimes(1); }); }); + + describe('workflow node lookups', () => { + const lookupUrl = 'http://localhost/internal/workflows/1052/workflow-nodes/dataTable_2/options/table'; + + beforeEach(() => { + useWorkflowIssuesStore.getState().reset(); + }); + + it('records a failed lookup against the node instead of toasting', async () => { + renderHook(() => useFetchInterceptor()); + + const response = createMockResponse({ + jsonData: {detail: "Table does not have primary key column 'id': dt_0_conversations", title: 'Error'}, + status: 500, + url: lookupUrl, + }); + + await act(async () => { + hoisted.registeredHandlers!.response(response); + + await Promise.resolve(); + }); + + expect(hoisted.toastError).not.toHaveBeenCalled(); + expect(useWorkflowIssuesStore.getState().liveIssues['dataTable_2|table|LOOKUP_FAILED'].message).toBe( + "Table does not have primary key column 'id': dt_0_conversations" + ); + }); + + it('clears the recorded failure when the same lookup succeeds', async () => { + useWorkflowIssuesStore.getState().recordLookupFailure('dataTable_2', 'table', 'stale'); + + renderHook(() => useFetchInterceptor()); + + await act(async () => { + hoisted.registeredHandlers!.response(createMockResponse({status: 200, url: lookupUrl})); + }); + + expect(useWorkflowIssuesStore.getState().liveIssues).toEqual({}); + }); + }); }); diff --git a/client/src/config/useFetchInterceptor.ts b/client/src/config/useFetchInterceptor.ts index cb4c196eda4..c4e42950ae9 100644 --- a/client/src/config/useFetchInterceptor.ts +++ b/client/src/config/useFetchInterceptor.ts @@ -2,6 +2,7 @@ import {useWorkspaceStore} from '@/pages/automation/stores/useWorkspaceStore'; import {buildLoginPath} from '@/shared/auth/login-redirect-utils'; import {useAuthenticationStore} from '@/shared/stores/useAuthenticationStore'; import {getCookie} from '@/shared/util/cookie-utils'; +import recordWorkflowNodeLookupResult from '@/shared/util/recordWorkflowNodeLookupResult'; import fetchIntercept from 'fetch-intercept'; import {useEffect, useRef} from 'react'; import {useNavigate} from 'react-router-dom'; @@ -168,6 +169,10 @@ export default function useFetchInterceptor() { return response; } + if (recordWorkflowNodeLookupResult(response)) { + return response; + } + // Endpoints that surface their own error state inline shouldn't also flash a // global toast. if ((response.status < 200 || response.status > 299) && handlesErrorInline(response.url)) { diff --git a/client/src/ee/pages/embedded/automation-workflow/AutomationWorkflow.tsx b/client/src/ee/pages/embedded/automation-workflow/AutomationWorkflow.tsx index d9533002e34..d44d7e06cb3 100644 --- a/client/src/ee/pages/embedded/automation-workflow/AutomationWorkflow.tsx +++ b/client/src/ee/pages/embedded/automation-workflow/AutomationWorkflow.tsx @@ -129,7 +129,7 @@ const AutomationWorkflow = () => {
diff --git a/client/src/ee/pages/embedded/integration/Integration.tsx b/client/src/ee/pages/embedded/integration/Integration.tsx index 137d0d9489b..8b4fb26a03e 100644 --- a/client/src/ee/pages/embedded/integration/Integration.tsx +++ b/client/src/ee/pages/embedded/integration/Integration.tsx @@ -55,7 +55,7 @@ const Integration = () => {
diff --git a/client/src/ee/pages/embedded/workflow-builder/config/tests/useFetchInterceptor.test.ts b/client/src/ee/pages/embedded/workflow-builder/config/tests/useFetchInterceptor.test.ts index d371960dd30..b4b968badea 100644 --- a/client/src/ee/pages/embedded/workflow-builder/config/tests/useFetchInterceptor.test.ts +++ b/client/src/ee/pages/embedded/workflow-builder/config/tests/useFetchInterceptor.test.ts @@ -1,3 +1,4 @@ +import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore'; import {act, renderHook} from '@testing-library/react'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; @@ -267,4 +268,45 @@ describe('useFetchInterceptor (embedded)', () => { }); }); }); + + describe('workflow node lookups', () => { + const lookupUrl = 'http://localhost/internal/workflows/1052/workflow-nodes/dataTable_2/options/table'; + + beforeEach(() => { + useWorkflowIssuesStore.getState().reset(); + }); + + it('records a failed lookup against the node instead of toasting', async () => { + renderHook(() => useFetchInterceptor()); + + const response = createMockResponse({ + jsonData: {detail: "Table does not have primary key column 'id': dt_0_conversations", title: 'Error'}, + status: 500, + url: lookupUrl, + }); + + await act(async () => { + hoisted.registeredHandlers!.response(response); + + await Promise.resolve(); + }); + + expect(hoisted.toastError).not.toHaveBeenCalled(); + expect(useWorkflowIssuesStore.getState().liveIssues['dataTable_2|table|LOOKUP_FAILED'].message).toBe( + "Table does not have primary key column 'id': dt_0_conversations" + ); + }); + + it('clears the recorded failure when the same lookup succeeds', async () => { + useWorkflowIssuesStore.getState().recordLookupFailure('dataTable_2', 'table', 'stale'); + + renderHook(() => useFetchInterceptor()); + + await act(async () => { + hoisted.registeredHandlers!.response(createMockResponse({status: 200, url: lookupUrl})); + }); + + expect(useWorkflowIssuesStore.getState().liveIssues).toEqual({}); + }); + }); }); diff --git a/client/src/ee/pages/embedded/workflow-builder/config/useFetchInterceptor.ts b/client/src/ee/pages/embedded/workflow-builder/config/useFetchInterceptor.ts index 76f1902200a..7f874717ee2 100644 --- a/client/src/ee/pages/embedded/workflow-builder/config/useFetchInterceptor.ts +++ b/client/src/ee/pages/embedded/workflow-builder/config/useFetchInterceptor.ts @@ -1,6 +1,7 @@ import {useWorkspaceStore} from '@/pages/automation/stores/useWorkspaceStore'; import {useAuthenticationStore} from '@/shared/stores/useAuthenticationStore'; import {useEnvironmentStore} from '@/shared/stores/useEnvironmentStore'; +import recordWorkflowNodeLookupResult from '@/shared/util/recordWorkflowNodeLookupResult'; import fetchIntercept from 'fetch-intercept'; import {useEffect, useRef} from 'react'; import {toast} from 'sonner'; @@ -86,6 +87,10 @@ export default function useFetchInterceptor() { return response; } + if (recordWorkflowNodeLookupResult(response)) { + return response; + } + const toastId = `fetch-error-${response.status}`; if (response.url.includes('/graphql')) { diff --git a/client/src/graphql/platform/configuration/validateWorkflow.graphql b/client/src/graphql/platform/configuration/validateWorkflow.graphql index d8e64a7f491..5233449d633 100644 --- a/client/src/graphql/platform/configuration/validateWorkflow.graphql +++ b/client/src/graphql/platform/configuration/validateWorkflow.graphql @@ -1,6 +1,13 @@ -query ValidateWorkflow($workflowDefinition: String!) { - validateWorkflow(workflow: $workflowDefinition) { +query ValidateWorkflow($workflowDefinition: String!, $environmentId: Long) { + validateWorkflow(workflow: $workflowDefinition, environmentId: $environmentId) { errors warnings + nodeIssues { + nodeName + propertyPath + kind + severity + message + } } } diff --git a/client/src/pages/automation/project/Project.tsx b/client/src/pages/automation/project/Project.tsx index 1cb1d71ae23..2428136c3c3 100644 --- a/client/src/pages/automation/project/Project.tsx +++ b/client/src/pages/automation/project/Project.tsx @@ -60,7 +60,7 @@ const Project = () => {
diff --git a/client/src/pages/platform/workflow-editor/WorkflowEditorLayout.tsx b/client/src/pages/platform/workflow-editor/WorkflowEditorLayout.tsx index 2642ca68a51..2b305f5a272 100644 --- a/client/src/pages/platform/workflow-editor/WorkflowEditorLayout.tsx +++ b/client/src/pages/platform/workflow-editor/WorkflowEditorLayout.tsx @@ -5,16 +5,22 @@ import './WorkflowEditorLayout.css'; import ClusterElementsCanvasDialog from '@/pages/platform/workflow-editor/components/ClusterElementsCanvasDialog'; import WorkflowNodeDetailsPanel from '@/pages/platform/workflow-editor/components/WorkflowNodeDetailsPanel'; import WorkflowTestChatPanel from '@/pages/platform/workflow-editor/components/workflow-test-chat/WorkflowTestChatPanel'; +import useDelayedUnmount from '@/pages/platform/workflow-editor/hooks/useDelayedUnmount'; import useWorkflowEditorLayout from '@/pages/platform/workflow-editor/hooks/useWorkflowEditorLayout'; +import useWorkflowIssues from '@/pages/platform/workflow-editor/hooks/useWorkflowIssues'; +import useWorkflowIssuesSweep from '@/pages/platform/workflow-editor/hooks/useWorkflowIssuesSweep'; +import useWorkflowIssuesValidation from '@/pages/platform/workflow-editor/hooks/useWorkflowIssuesValidation'; import {useWorkflowLayout} from '@/pages/platform/workflow-editor/hooks/useWorkflowLayout'; import {useWorkflowEditor} from '@/pages/platform/workflow-editor/providers/workflowEditorProvider'; import useRightSidebarStore from '@/pages/platform/workflow-editor/stores/useRightSidebarStore'; import useWorkflowEditorStore from '@/pages/platform/workflow-editor/stores/useWorkflowEditorStore'; +import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore'; import useCopilotLayoutShifted from '@/shared/components/copilot/hooks/useCopilotLayoutShifted'; import useCopilotPanelStore from '@/shared/components/copilot/stores/useCopilotPanelStore'; import useCopilotPostTurnRegistry from '@/shared/components/copilot/stores/useCopilotPostTurnRegistry'; import useCopilotStateContributorRegistry from '@/shared/components/copilot/stores/useCopilotStateContributorRegistry'; import {Source, useCopilotStore} from '@/shared/components/copilot/stores/useCopilotStore'; +import {ISSUES_SIDEBAR_EXIT_DURATION} from '@/shared/constants'; import {ProjectWorkflowKeys} from '@/shared/queries/automation/projectWorkflows.queries'; import {useQueryClient} from '@tanstack/react-query'; import {Suspense, lazy, useEffect, useState} from 'react'; @@ -22,7 +28,6 @@ import {useParams} from 'react-router-dom'; import {twMerge} from 'tailwind-merge'; import {useShallow} from 'zustand/shallow'; -import ErrorsBanner from './components/ErrorsBanner'; import SubflowBanner from './components/SubflowBanner'; import WorkflowCodeEditorSheet from './components/WorkflowCodeEditorSheet'; import { @@ -39,6 +44,7 @@ import {clearAllWorkflowMutations} from './utils/workflowMutationGuard'; const DataPillPanel = lazy(() => import('./components/datapills/DataPillPanel')); const WorkflowEditor = lazy(() => import('./components/WorkflowEditor')); +const WorkflowIssuesSidebar = lazy(() => import('./components/WorkflowIssuesSidebar')); const WorkflowRightSidebar = lazy(() => import('./components/WorkflowRightSidebar')); const WorkflowNodesSidebar = lazy(() => import('./components/WorkflowNodesSidebar')); @@ -64,14 +70,16 @@ const WorkflowEditorLayout = ({ workflowReferenceId, }: WorkflowEditorLayoutProps) => { const [clusterDialogMounted, setClusterDialogMounted] = useState(false); - const [rightSidebarMounted, setRightSidebarMounted] = useState(false); - const [rightSidebarVisible, setRightSidebarVisible] = useState(false); const copilotLayoutShifted = useCopilotLayoutShifted(); const copilotPanelOpen = useCopilotPanelStore((state) => state.copilotPanelOpen); const rightSidebarOpen = useRightSidebarStore((state) => state.rightSidebarOpen); const workflow = useWorkflowDataStore((state) => state.workflow); const currentNode = useWorkflowNodeDetailsPanelStore((state) => state.currentNode); + const issuesSidebarOpen = useWorkflowIssuesStore((state) => state.issuesSidebarOpen); + const workflowNodeDetailsPanelOpen = useWorkflowNodeDetailsPanelStore( + (state) => state.workflowNodeDetailsPanelOpen + ); const { clusterElementsCanvasOpen, setShowWorkflowCodeEditorSheet, @@ -100,6 +108,7 @@ const WorkflowEditorLayout = ({ handleCopilotClick, handleWorkflowCodeEditorClick, handleWorkflowInputsClick, + handleWorkflowIssuesClick, handleWorkflowOutputsClick, isWorkflowNodeOutputsPending, previousComponentDefinitions, @@ -108,12 +117,23 @@ const WorkflowEditorLayout = ({ workflowTestConfiguration, } = useWorkflowLayout(includeComponents); + useWorkflowIssuesSweep(); + useWorkflowIssuesValidation(); + + const issues = useWorkflowIssues(); + const {invalidateWorkflowQueries, updateWorkflowMutation} = useWorkflowEditor(); const {handleClusterElementsCanvasOpenChange, isMainRootClusterElement} = useWorkflowEditorLayout(); const queryClient = useQueryClient(); const {projectId, projectWorkflowId} = useParams(); + const {mounted: rightSidebarMounted, visible: rightSidebarVisible} = useDelayedUnmount(rightSidebarOpen); + const {mounted: issuesSidebarMounted, visible: issuesSidebarVisible} = useDelayedUnmount( + issuesSidebarOpen, + workflowNodeDetailsPanelOpen ? 0 : ISSUES_SIDEBAR_EXIT_DURATION + ); + useEffect(() => { return useCopilotStateContributorRegistry.getState().register(() => { const activeWorkflow = useWorkflowDataStore.getState().workflow; @@ -139,40 +159,6 @@ const WorkflowEditorLayout = ({ }); }, [projectId, projectWorkflowId, queryClient]); - useEffect(() => { - let outerRafId: number | undefined; - let innerRafId: number | undefined; - let timerId: ReturnType | undefined; - - if (rightSidebarOpen) { - setRightSidebarMounted(true); - - outerRafId = requestAnimationFrame(() => { - innerRafId = requestAnimationFrame(() => { - setRightSidebarVisible(true); - }); - }); - } else { - setRightSidebarVisible(false); - - timerId = setTimeout(() => setRightSidebarMounted(false), 300); - } - - return () => { - if (outerRafId !== undefined) { - cancelAnimationFrame(outerRafId); - } - - if (innerRafId !== undefined) { - cancelAnimationFrame(innerRafId); - } - - if (timerId !== undefined) { - clearTimeout(timerId); - } - }; - }, [rightSidebarOpen]); - useEffect(() => { if (clusterElementsCanvasOpen) { setClusterDialogMounted(true); @@ -188,6 +174,7 @@ const WorkflowEditorLayout = ({ clearAllWorkflowMutations(); useWorkflowNodeDetailsPanelStore.getState().clearPendingSaveNodeNames(); + useWorkflowIssuesStore.getState().reset(); }; }, []); @@ -202,8 +189,6 @@ const WorkflowEditorLayout = ({ >
- -
{componentDefinitions && taskDispatcherDefinitions && ( @@ -229,18 +214,28 @@ const WorkflowEditorLayout = ({ )} + {issuesSidebarMounted && ( + + + + )} + {componentDefinitions && taskDispatcherDefinitions && ( + } > { - const [dismissed, setDismissed] = useState(false); - - const workflow = useWorkflowDataStore((state) => state.workflow); - const setShowWorkflowCodeEditorSheet = useWorkflowEditorStore((state) => state.setShowWorkflowCodeEditorSheet); - - const duplicateNodeNames = useMemo( - () => getDuplicateNodeNames(workflow.tasks, workflow.triggers), - [workflow.tasks, workflow.triggers] - ); - - const handleOpenCodeEditorClick = useCallback( - () => setShowWorkflowCodeEditorSheet(true), - [setShowWorkflowCodeEditorSheet] - ); - - const handleDismiss = useCallback(() => setDismissed(true), []); - - useEffect(() => { - setDismissed(false); - }, [duplicateNodeNames]); - - if (duplicateNodeNames.length === 0 || dismissed) { - return null; - } - - return ( -
- - - - {duplicateNodeNames.length === 1 ? 'Duplicate node name: ' : 'Duplicate node names: '} - - {duplicateNodeNames.join(', ')} - - { - '. Node names must be unique — the graph may render incorrectly until this is fixed in the code editor.' - } - - -
-
-
- ); -}; - -export default ErrorsBanner; diff --git a/client/src/pages/platform/workflow-editor/components/WorkflowCodeEditorSheet.tsx b/client/src/pages/platform/workflow-editor/components/WorkflowCodeEditorSheet.tsx index 592f7493c39..91daf7a0411 100644 --- a/client/src/pages/platform/workflow-editor/components/WorkflowCodeEditorSheet.tsx +++ b/client/src/pages/platform/workflow-editor/components/WorkflowCodeEditorSheet.tsx @@ -14,6 +14,7 @@ import CopilotPanel from '@/shared/components/copilot/CopilotPanel'; import {Workflow, WorkflowTestConfiguration} from '@/shared/middleware/platform/configuration'; import {useFeatureFlagsStore} from '@/shared/stores/useFeatureFlagsStore'; import { + AlertTriangleIcon, ChevronDownIcon, CodeXmlIcon, InfoIcon, @@ -71,10 +72,12 @@ const WorkflowCodeEditorSheet = ({ handleWorkflowTestConfigurationDialog, hasErrors, projectName, - setErrorPanelRef, setErrorsAccordionOpen, + setWarningsAccordionOpen, showWorkflowTestConfigurationDialog, unsavedChangesAlertDialogOpen, + warnings, + warningsAccordionOpen, workflowIsRunning, workflowTestExecution, } = useWorkflowCodeEditorSheet({invalidateWorkflowQueries, onSheetOpenClose, workflow}); @@ -93,7 +96,7 @@ const WorkflowCodeEditorSheet = ({ onFocusOutside={(event) => event.preventDefault()} onPointerDownOutside={(event) => event.preventDefault()} > -
+
@@ -202,11 +205,8 @@ const WorkflowCodeEditorSheet = ({
-
- +
+ }> - {errors?.length > 0 && ( - setErrorsAccordionOpen(!errorsAccordionOpen)} - panelRef={setErrorPanelRef} - > -
- - - Errors ({errors.length}) - - -
- - {errorsAccordionOpen && ( - -
    - {errors.map((error, index) => ( -
  • - {error} -
  • - ))} -
-
- )} -
- )} - {(workflowIsRunning || (workflowTestExecution && showBottomPanel)) && ( {workflowIsRunning ? ( @@ -289,6 +247,80 @@ const WorkflowCodeEditorSheet = ({ )}
+ + {errors?.length > 0 && ( +
+ + + {errorsAccordionOpen && ( + +
    + {errors.map((error, index) => ( +
  • + {error} +
  • + ))} +
+
+ )} +
+ )} + + {warnings?.length > 0 && ( +
+ + + {warningsAccordionOpen && ( + +
    + {warnings.map((warning, index) => ( +
  • + {warning} +
  • + ))} +
+
+ )} +
+ )}
diff --git a/client/src/pages/platform/workflow-editor/components/WorkflowEditor.tsx b/client/src/pages/platform/workflow-editor/components/WorkflowEditor.tsx index 44ab97c534c..c2093249a12 100644 --- a/client/src/pages/platform/workflow-editor/components/WorkflowEditor.tsx +++ b/client/src/pages/platform/workflow-editor/components/WorkflowEditor.tsx @@ -1,6 +1,6 @@ import '@xyflow/react/dist/base.css'; import useWorkflowDataStore from '@/pages/platform/workflow-editor/stores/useWorkflowDataStore'; -import {CANVAS_BACKGROUND_COLOR} from '@/shared/constants'; +import {CANVAS_BACKGROUND_COLOR, CANVAS_TOP_OFFSET} from '@/shared/constants'; import { ComponentDefinitionBasic, TaskDispatcherDefinitionBasic, @@ -12,8 +12,10 @@ import {twMerge} from 'tailwind-merge'; import {useShallow} from 'zustand/react/shallow'; import useWorkflowEditorCanvas from '../hooks/useWorkflowEditorCanvas'; +import {WorkflowEditorReadOnlyContext} from '../providers/workflowEditorReadOnlyContext'; import NodeActionsHint from './NodeActionsHint'; import WorkflowEditorToolbar from './WorkflowEditorToolbar'; +import WorkflowIssuesNote from './WorkflowIssuesNote'; type ConditionalWorkflowEditorPropsType = | { @@ -37,6 +39,8 @@ type WorkflowEditorPropsType = { taskDispatcherDefinitions: TaskDispatcherDefinitionBasic[]; }; +const CANVAS_DEFAULT_VIEWPORT = {x: 0, y: CANVAS_TOP_OFFSET, zoom: 1}; + const WorkflowEditor = ({ className, componentDefinitions, @@ -82,38 +86,43 @@ const WorkflowEditor = ({ }, [fitsViewOnLoad, fitView, nodes, nodesInitialized, onFitView]); return ( -
- - + +
+ + - {!readOnlyWorkflow && nodes.length > 0 && } + {!readOnlyWorkflow && nodes.length > 0 && } />} - {!preview && } - -
+ {!preview && ( + + )} +
+
+ ); }; diff --git a/client/src/pages/platform/workflow-editor/components/WorkflowEditorToolbar.tsx b/client/src/pages/platform/workflow-editor/components/WorkflowEditorToolbar.tsx index 19837be32da..986b0237135 100644 --- a/client/src/pages/platform/workflow-editor/components/WorkflowEditorToolbar.tsx +++ b/client/src/pages/platform/workflow-editor/components/WorkflowEditorToolbar.tsx @@ -50,7 +50,7 @@ const WorkflowEditorToolbar = ({enableUndoRedo = false, readOnly = false}: Workf const handleZoomOut = useCallback(() => zoomOut({duration: 300}), [zoomOut]); const handleFitView = useCallback(() => { - fitView({duration: 500, minZoom: 0.2}); + fitView({duration: 500, minZoom: 0.2, padding: {bottom: '16px', left: '16px', right: '16px', top: '64px'}}); }, [fitView]); const handleToggleLayout = useCallback(() => { diff --git a/client/src/pages/platform/workflow-editor/components/WorkflowIssuesNote.tsx b/client/src/pages/platform/workflow-editor/components/WorkflowIssuesNote.tsx new file mode 100644 index 00000000000..229b2f40611 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/components/WorkflowIssuesNote.tsx @@ -0,0 +1,72 @@ +import Button from '@/components/Button/Button'; +import {Panel} from '@xyflow/react'; +import {AlertTriangleIcon} from 'lucide-react'; +import {ReactNode, useCallback} from 'react'; +import {twMerge} from 'tailwind-merge'; + +import useFlowCenterOffset from '../hooks/useFlowCenterOffset'; +import useWorkflowIssues from '../hooks/useWorkflowIssues'; +import {useWorkflowEditorReadOnly} from '../providers/workflowEditorReadOnlyContext'; +import describeWorkflowIssueCounts from '../utils/describeWorkflowIssueCounts'; +import openIssuesSidebar from '../utils/openIssuesSidebar'; + +interface WorkflowIssuesNoteProps { + fallback: ReactNode; +} + +const WorkflowIssuesNote = ({fallback}: WorkflowIssuesNoteProps) => { + const readOnly = useWorkflowEditorReadOnly(); + const flowCenterOffset = useFlowCenterOffset(); + const issues = useWorkflowIssues(); + + const handleViewClick = useCallback(() => openIssuesSidebar(), []); + + if (readOnly) { + return null; + } + + if (issues.length === 0) { + return fallback; + } + + const errorCount = issues.filter((issue) => issue.severity === 'ERROR').length; + const warningCount = issues.length - errorCount; + const hasErrors = errorCount > 0; + + return ( + +
+ + + + {describeWorkflowIssueCounts(errorCount, warningCount)} in this workflow + + +
+
+ ); +}; + +export default WorkflowIssuesNote; diff --git a/client/src/pages/platform/workflow-editor/components/WorkflowIssuesSidebar.tsx b/client/src/pages/platform/workflow-editor/components/WorkflowIssuesSidebar.tsx new file mode 100644 index 00000000000..adb081f581b --- /dev/null +++ b/client/src/pages/platform/workflow-editor/components/WorkflowIssuesSidebar.tsx @@ -0,0 +1,102 @@ +import {NodeDataType} from '@/shared/types'; +import {AlertTriangleIcon} from 'lucide-react'; +import {useCallback, useMemo} from 'react'; +import {twMerge} from 'tailwind-merge'; + +import useWorkflowIssues from '../hooks/useWorkflowIssues'; +import useWorkflowDataStore from '../stores/useWorkflowDataStore'; +import {WorkflowIssueI, getWorkflowIssueKey} from '../stores/useWorkflowIssuesStore'; +import describeWorkflowIssueCounts from '../utils/describeWorkflowIssueCounts'; +import openNodeDetails from '../utils/openNodeDetails'; + +interface WorkflowIssuesSidebarProps { + visible: boolean; +} + +const WorkflowIssuesSidebar = ({visible}: WorkflowIssuesSidebarProps) => { + const nodes = useWorkflowDataStore((state) => state.nodes); + + const issues = useWorkflowIssues(); + + const issuesByNode = useMemo(() => { + const grouped = new Map>(); + + for (const issue of issues) { + grouped.set(issue.nodeName, [...(grouped.get(issue.nodeName) ?? []), issue]); + } + + return [...grouped.entries()]; + }, [issues]); + + const heading = useMemo(() => { + if (issues.length === 0) { + return 'Workflow Issues'; + } + + const errorCount = issues.filter((issue) => issue.severity === 'ERROR').length; + const warningCount = issues.filter((issue) => issue.severity !== 'ERROR').length; + + return `Workflow Issues (${describeWorkflowIssueCounts(errorCount, warningCount)})`; + }, [issues]); + + const handleIssueClick = useCallback( + (nodeName: string) => { + const node = nodes.find((currentNode) => (currentNode.data as NodeDataType).name === nodeName); + + if (node) { + openNodeDetails(node.data as NodeDataType, 'properties'); + } + }, + [nodes] + ); + + return ( + + ); +}; + +export default WorkflowIssuesSidebar; diff --git a/client/src/pages/platform/workflow-editor/components/WorkflowNodeDetailsPanel.tsx b/client/src/pages/platform/workflow-editor/components/WorkflowNodeDetailsPanel.tsx index 966642d3ae5..9a70506f47c 100644 --- a/client/src/pages/platform/workflow-editor/components/WorkflowNodeDetailsPanel.tsx +++ b/client/src/pages/platform/workflow-editor/components/WorkflowNodeDetailsPanel.tsx @@ -9,6 +9,7 @@ import DescriptionTab from '@/pages/platform/workflow-editor/components/node-det import ConnectionTab from '@/pages/platform/workflow-editor/components/node-details-tabs/connection-tab/ConnectionTab'; import OutputTab from '@/pages/platform/workflow-editor/components/node-details-tabs/output-tab/OutputTab'; import Properties from '@/pages/platform/workflow-editor/components/properties/Properties'; +import useWorkflowNodeDetailsPanelStore from '@/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore'; import useCopilotLayoutShifted from '@/shared/components/copilot/hooks/useCopilotLayoutShifted'; import { ActionDefinition, @@ -89,6 +90,10 @@ const WorkflowNodeDetailsPanel = ({ workflowNodeOutputs, }); + const panelOpenedFromIssuesSidebar = useWorkflowNodeDetailsPanelStore( + (state) => state.panelOpenedFromIssuesSidebar + ); + const nodeVersion = getNodeVersion(currentWorkflowNode); const availableVersions = useMemo( @@ -108,7 +113,7 @@ const WorkflowNodeDetailsPanel = ({ className={twMerge( 'absolute top-2 bottom-6 z-10 w-screen max-w-workflow-node-details-panel-width overflow-hidden rounded-md border border-stroke-neutral-secondary bg-background', copilotLayoutShifted ? 'right-[57px]' : 'right-[69px]', - !className && 'animate-[slideInFromRight_300ms_ease-out]', + !className && !panelOpenedFromIssuesSidebar && 'animate-[slideInFromRight_300ms_ease-out]', className )} > diff --git a/client/src/pages/platform/workflow-editor/components/WorkflowNodeIssueBadge.tsx b/client/src/pages/platform/workflow-editor/components/WorkflowNodeIssueBadge.tsx new file mode 100644 index 00000000000..c9eb69e4c91 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/components/WorkflowNodeIssueBadge.tsx @@ -0,0 +1,37 @@ +import {AlertTriangleIcon} from 'lucide-react'; +import {twMerge} from 'tailwind-merge'; + +import useNodeIssues from '../hooks/useNodeIssues'; +import {useWorkflowEditorReadOnly} from '../providers/workflowEditorReadOnlyContext'; + +interface WorkflowNodeIssueBadgeProps { + clusterElement?: boolean; + nodeName: string; +} + +const WorkflowNodeIssueBadge = ({clusterElement, nodeName}: WorkflowNodeIssueBadgeProps) => { + const readOnly = useWorkflowEditorReadOnly(); + const {count, severity, title} = useNodeIssues(nodeName, clusterElement); + + if (readOnly || count === 0) { + return null; + } + + return ( + + + + ); +}; + +export default WorkflowNodeIssueBadge; diff --git a/client/src/pages/platform/workflow-editor/components/WorkflowRightSidebar.tsx b/client/src/pages/platform/workflow-editor/components/WorkflowRightSidebar.tsx index 0b0d4e6f430..bc14a70b9f8 100644 --- a/client/src/pages/platform/workflow-editor/components/WorkflowRightSidebar.tsx +++ b/client/src/pages/platform/workflow-editor/components/WorkflowRightSidebar.tsx @@ -2,16 +2,22 @@ import Button from '@/components/Button/Button'; import {Tooltip, TooltipContent, TooltipTrigger} from '@/components/ui/tooltip'; import {useApplicationInfoStore} from '@/shared/stores/useApplicationInfoStore'; import {useFeatureFlagsStore} from '@/shared/stores/useFeatureFlagsStore'; -import {BlocksIcon, CableIcon, Code2Icon, SlidersIcon, SparklesIcon} from 'lucide-react'; +import {AlertTriangleIcon, BlocksIcon, CableIcon, Code2Icon, SlidersIcon, SparklesIcon} from 'lucide-react'; import {useMemo} from 'react'; import {twMerge} from 'tailwind-merge'; +import {WorkflowIssueSeverityType} from '../stores/useWorkflowIssuesStore'; + export interface WorkflowRightSidebarProps { copilotPanelOpen: boolean; + issueCount: number; + issueSeverity?: WorkflowIssueSeverityType; + issuesSidebarOpen: boolean; onComponentsAndFlowControlsClick: () => void; onCopilotClick: () => void; onWorkflowCodeEditorClick: () => void; onWorkflowInputsClick: () => void; + onWorkflowIssuesClick: () => void; onWorkflowOutputsClick: () => void; rightSidebarOpen: boolean; showCopilot?: boolean; @@ -19,10 +25,14 @@ export interface WorkflowRightSidebarProps { } const WorkflowRightSidebar = ({ copilotPanelOpen, + issueCount, + issueSeverity, + issuesSidebarOpen, onComponentsAndFlowControlsClick, onCopilotClick, onWorkflowCodeEditorClick, onWorkflowInputsClick, + onWorkflowIssuesClick, onWorkflowOutputsClick, rightSidebarOpen, showCopilot = true, @@ -38,6 +48,30 @@ const WorkflowRightSidebar = ({ const rightSidebarNavigation = useMemo( () => [ + ...[ + { + icon: ( + + + + {issueCount > 0 && ( + + {issueCount} + + )} + + ), + name: 'Workflow Issues', + onClick: onWorkflowIssuesClick, + }, + ], ...[ { icon: , @@ -83,7 +117,16 @@ const WorkflowRightSidebar = ({ return true; }), // eslint-disable-next-line react-hooks/exhaustive-deps - [copilotEnabled, copilotPanelOpen, ff_1840, rightSidebarOpen, showCopilot] + [ + copilotEnabled, + copilotPanelOpen, + ff_1840, + issueCount, + issueSeverity, + issuesSidebarOpen, + rightSidebarOpen, + showCopilot, + ] ); const activeItemStyling = @@ -94,7 +137,8 @@ const WorkflowRightSidebar = ({ {rightSidebarNavigation.map((item) => { const isActive = (item.name === 'Components & Flow Controls' && rightSidebarOpen) || - (item.name === 'Copilot' && copilotPanelOpen); + (item.name === 'Copilot' && copilotPanelOpen) || + (item.name === 'Workflow Issues' && issuesSidebarOpen); return ( diff --git a/client/src/pages/platform/workflow-editor/components/hooks/useWorkflowNodeDetailsPanel.ts b/client/src/pages/platform/workflow-editor/components/hooks/useWorkflowNodeDetailsPanel.ts index 2c12fa508ab..a2238cdd8cd 100644 --- a/client/src/pages/platform/workflow-editor/components/hooks/useWorkflowNodeDetailsPanel.ts +++ b/client/src/pages/platform/workflow-editor/components/hooks/useWorkflowNodeDetailsPanel.ts @@ -1261,7 +1261,12 @@ export default function useWorkflowNodeDetailsPanel({ return; } - if (activeTab === 'properties' && !operationDataMissing && !currentOperationProperties?.length) { + if ( + activeTab === 'properties' && + !operationDataMissing && + currentOperationDefinition && + !currentOperationDefinition.properties?.length + ) { setActiveTab('description'); return; @@ -1270,6 +1275,7 @@ export default function useWorkflowNodeDetailsPanel({ }, [ activeTab, currentActionDefinition?.outputDefined, + currentOperationDefinition, currentActionFetched, currentOperationProperties?.length, currentComponentDefinition?.name, diff --git a/client/src/pages/platform/workflow-editor/components/properties/components/property-code-editor/property-code-editor-dialog/PropertyCodeEditorDialogRightPanelConnections.tsx b/client/src/pages/platform/workflow-editor/components/properties/components/property-code-editor/property-code-editor-dialog/PropertyCodeEditorDialogRightPanelConnections.tsx index 478c914e2b3..63032245eb1 100644 --- a/client/src/pages/platform/workflow-editor/components/properties/components/property-code-editor/property-code-editor-dialog/PropertyCodeEditorDialogRightPanelConnections.tsx +++ b/client/src/pages/platform/workflow-editor/components/properties/components/property-code-editor/property-code-editor-dialog/PropertyCodeEditorDialogRightPanelConnections.tsx @@ -36,7 +36,7 @@ const PropertyCodeEditorDialogRightPanelConnections = ({ }); return ( - + Connections diff --git a/client/src/pages/platform/workflow-editor/components/properties/components/property-code-editor/property-code-editor-dialog/PropertyCodeEditorDialogRightPanelInput.tsx b/client/src/pages/platform/workflow-editor/components/properties/components/property-code-editor/property-code-editor-dialog/PropertyCodeEditorDialogRightPanelInput.tsx index d2dc71c33b0..ce7cd5b81b8 100644 --- a/client/src/pages/platform/workflow-editor/components/properties/components/property-code-editor/property-code-editor-dialog/PropertyCodeEditorDialogRightPanelInput.tsx +++ b/client/src/pages/platform/workflow-editor/components/properties/components/property-code-editor/property-code-editor-dialog/PropertyCodeEditorDialogRightPanelInput.tsx @@ -18,7 +18,7 @@ const PropertyCodeEditorDialogRightPanelInput = ({input}: PropertyCodeEditorDial usePropertyCodeEditorDialogRightPanelInput({input}); return ( - +
Input diff --git a/client/src/pages/platform/workflow-editor/components/tests/ErrorsBanner.test.tsx b/client/src/pages/platform/workflow-editor/components/tests/ErrorsBanner.test.tsx deleted file mode 100644 index 31de21220be..00000000000 --- a/client/src/pages/platform/workflow-editor/components/tests/ErrorsBanner.test.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import {WorkflowTask} from '@/shared/middleware/platform/configuration'; -import {fireEvent, render, screen} from '@/shared/util/test-utils'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; - -import ErrorsBanner from '../ErrorsBanner'; - -const task = (name: string): WorkflowTask => ({name, type: 'example/v1/action'}) as WorkflowTask; - -const hoisted = vi.hoisted(() => ({ - setShowWorkflowCodeEditorSheet: vi.fn(), - workflowState: {workflow: {tasks: [] as WorkflowTask[], triggers: []}}, -})); - -vi.mock('@/pages/platform/workflow-editor/stores/useWorkflowDataStore', () => ({ - default: (selector: (state: typeof hoisted.workflowState) => unknown) => selector(hoisted.workflowState), -})); - -vi.mock('@/pages/platform/workflow-editor/stores/useWorkflowEditorStore', () => { - const state = {setShowWorkflowCodeEditorSheet: hoisted.setShowWorkflowCodeEditorSheet}; - - return {default: (selector: (currentState: typeof state) => unknown) => selector(state)}; -}); - -const getDismissButton = () => - screen.getAllByRole('button').find((button) => !button.textContent?.includes('Open code editor')); - -describe('DuplicateNodeNamesBanner', () => { - beforeEach(() => { - hoisted.setShowWorkflowCodeEditorSheet.mockClear(); - hoisted.workflowState.workflow = {tasks: [], triggers: []}; - }); - - it('renders nothing when all node names are unique', () => { - hoisted.workflowState.workflow = {tasks: [task('a'), task('b')], triggers: []}; - - const {container} = render(); - - expect(container).toBeEmptyDOMElement(); - }); - - it('renders a singular label for a single duplicate name', () => { - hoisted.workflowState.workflow = {tasks: [task('dup'), task('dup')], triggers: []}; - - render(); - - expect(screen.getByText(/Duplicate node name:/)).toBeInTheDocument(); - expect(screen.getByText('dup')).toBeInTheDocument(); - }); - - it('renders a plural label and joins multiple duplicate names', () => { - hoisted.workflowState.workflow = {tasks: [task('a'), task('a'), task('b'), task('b')], triggers: []}; - - render(); - - expect(screen.getByText(/Duplicate node names:/)).toBeInTheDocument(); - expect(screen.getByText('a, b')).toBeInTheDocument(); - }); - - it('opens the code editor sheet when the action button is clicked', () => { - hoisted.workflowState.workflow = {tasks: [task('dup'), task('dup')], triggers: []}; - - render(); - - fireEvent.click(screen.getByText('Open code editor')); - - expect(hoisted.setShowWorkflowCodeEditorSheet).toHaveBeenCalledWith(true); - }); - - it('hides the banner once it is dismissed', () => { - hoisted.workflowState.workflow = {tasks: [task('dup'), task('dup')], triggers: []}; - - render(); - - const dismissButton = getDismissButton(); - - expect(dismissButton).toBeDefined(); - - fireEvent.click(dismissButton!); - - expect(screen.queryByText(/Duplicate node name:/)).not.toBeInTheDocument(); - }); -}); diff --git a/client/src/pages/platform/workflow-editor/components/tests/WorkflowIssuesNote.test.tsx b/client/src/pages/platform/workflow-editor/components/tests/WorkflowIssuesNote.test.tsx new file mode 100644 index 00000000000..59d2838e0c8 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/components/tests/WorkflowIssuesNote.test.tsx @@ -0,0 +1,57 @@ +import {render, screen} from '@/shared/util/test-utils'; +import {ReactFlowProvider} from '@xyflow/react'; +import {ReactNode} from 'react'; +import {beforeEach, describe, expect, it} from 'vitest'; + +import {WorkflowEditorReadOnlyContext} from '../../providers/workflowEditorReadOnlyContext'; +import useWorkflowIssuesStore from '../../stores/useWorkflowIssuesStore'; +import WorkflowIssuesNote from '../WorkflowIssuesNote'; + +const renderNote = (readOnly = false, fallback: ReactNode =
hint
) => + render( + + + + + + ); + +describe('WorkflowIssuesNote', () => { + beforeEach(() => { + useWorkflowIssuesStore.getState().reset(); + }); + + it('renders the fallback when there are no issues', () => { + renderNote(); + + expect(screen.getByText('hint')).toBeInTheDocument(); + }); + + it('replaces the fallback with a count and opens the sidebar on View', () => { + useWorkflowIssuesStore.getState().setValidatorIssues([ + {kind: 'MISSING_REQUIRED', message: 'a', nodeName: 'n_1', severity: 'ERROR', source: 'VALIDATOR'}, + {kind: 'MISSING_REQUIRED', message: 'b', nodeName: 'n_2', severity: 'WARNING', source: 'VALIDATOR'}, + ]); + + renderNote(); + + expect(screen.queryByText('hint')).not.toBeInTheDocument(); + expect(screen.getByText('1 error, 1 warning in this workflow')).toBeInTheDocument(); + + screen.getByRole('button', {name: 'View'}).click(); + + expect(useWorkflowIssuesStore.getState().issuesSidebarOpen).toBe(true); + }); + + it('renders nothing at all in read-only mode, not even the fallback', () => { + useWorkflowIssuesStore + .getState() + .setValidatorIssues([ + {kind: 'MISSING_REQUIRED', message: 'a', nodeName: 'n_1', severity: 'ERROR', source: 'VALIDATOR'}, + ]); + + const {container} = renderNote(true); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/client/src/pages/platform/workflow-editor/components/tests/WorkflowIssuesSidebar.test.tsx b/client/src/pages/platform/workflow-editor/components/tests/WorkflowIssuesSidebar.test.tsx new file mode 100644 index 00000000000..ca463e751a0 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/components/tests/WorkflowIssuesSidebar.test.tsx @@ -0,0 +1,62 @@ +import {NodeDataType} from '@/shared/types'; +import {fireEvent, render, screen} from '@/shared/util/test-utils'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import useWorkflowIssuesStore from '../../stores/useWorkflowIssuesStore'; +import WorkflowIssuesSidebar from '../WorkflowIssuesSidebar'; + +const hoisted = vi.hoisted(() => ({ + openNodeDetails: vi.fn(), +})); + +vi.mock('../../utils/openNodeDetails', () => ({default: hoisted.openNodeDetails})); + +const nodeData = {componentName: 'dataTable', name: 'dataTable_2', workflowNodeName: 'dataTable_2'} as NodeDataType; + +vi.mock('../../stores/useWorkflowDataStore', () => ({ + default: (selector: (state: {nodes: Array<{data: NodeDataType; id: string}>}) => unknown) => + selector({nodes: [{data: nodeData, id: 'dataTable_2'}]}), +})); + +describe('WorkflowIssuesSidebar', () => { + beforeEach(() => { + hoisted.openNodeDetails.mockClear(); + useWorkflowIssuesStore.getState().reset(); + }); + + it('shows an empty state when there are no issues', () => { + render(); + + expect(screen.getByText('No issues found')).toBeInTheDocument(); + }); + + it('groups issues by node and opens the node when a row is clicked', () => { + useWorkflowIssuesStore.getState().setValidatorIssues([ + { + kind: 'MISSING_RESOURCE', + message: "Table does not have primary key column 'id': dt_0_conversations", + nodeName: 'dataTable_2', + propertyPath: 'table', + severity: 'ERROR', + source: 'VALIDATOR', + }, + { + kind: 'MISSING_REQUIRED', + message: 'Missing required property: id', + nodeName: 'dataTable_2', + propertyPath: 'id', + severity: 'ERROR', + source: 'VALIDATOR', + }, + ]); + + render(); + + expect(screen.getAllByRole('heading', {level: 3})).toHaveLength(1); + expect(screen.getByText('dataTable_2')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Missing required property: id')); + + expect(hoisted.openNodeDetails).toHaveBeenCalledWith(nodeData, 'properties'); + }); +}); diff --git a/client/src/pages/platform/workflow-editor/hooks/tests/useDelayedUnmount.test.ts b/client/src/pages/platform/workflow-editor/hooks/tests/useDelayedUnmount.test.ts new file mode 100644 index 00000000000..551b87e57e4 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/hooks/tests/useDelayedUnmount.test.ts @@ -0,0 +1,95 @@ +import useDelayedUnmount from '@/pages/platform/workflow-editor/hooks/useDelayedUnmount'; +import {act, renderHook} from '@testing-library/react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const flushAnimationFrames = async () => { + await act(async () => { + await Promise.resolve(); + }); +}; + +describe('useDelayedUnmount', () => { + beforeEach(() => { + vi.useFakeTimers(); + + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('mounts and shows an element that opens', async () => { + const {result} = renderHook(() => useDelayedUnmount(true)); + + await flushAnimationFrames(); + + expect(result.current.mounted).toBe(true); + expect(result.current.visible).toBe(true); + }); + + it('keeps a closed element unmounted', () => { + const {result} = renderHook(() => useDelayedUnmount(false)); + + expect(result.current.mounted).toBe(false); + expect(result.current.visible).toBe(false); + }); + + it('hides before unmounting so the transition can run', async () => { + const {rerender, result} = renderHook(({open}) => useDelayedUnmount(open, 300), { + initialProps: {open: true}, + }); + + await flushAnimationFrames(); + + rerender({open: false}); + + expect(result.current.visible).toBe(false); + expect(result.current.mounted).toBe(true); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(result.current.mounted).toBe(false); + }); + + it('unmounts at once when the caller asks for no exit animation', async () => { + const {rerender, result} = renderHook(({open}) => useDelayedUnmount(open, 0), { + initialProps: {open: true}, + }); + + await flushAnimationFrames(); + + rerender({open: false}); + + act(() => { + vi.advanceTimersByTime(0); + }); + + expect(result.current.mounted).toBe(false); + }); + + it('cancels the pending unmount when it is reopened', async () => { + const {rerender, result} = renderHook(({open}) => useDelayedUnmount(open, 300), { + initialProps: {open: true}, + }); + + await flushAnimationFrames(); + + rerender({open: false}); + rerender({open: true}); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(result.current.mounted).toBe(true); + }); +}); diff --git a/client/src/pages/platform/workflow-editor/hooks/tests/useFlowCenterOffset.test.ts b/client/src/pages/platform/workflow-editor/hooks/tests/useFlowCenterOffset.test.ts new file mode 100644 index 00000000000..148cf5f6af5 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/hooks/tests/useFlowCenterOffset.test.ts @@ -0,0 +1,96 @@ +import useFlowCenterOffset from '@/pages/platform/workflow-editor/hooks/useFlowCenterOffset'; +import useLayoutDirectionStore from '@/pages/platform/workflow-editor/stores/useLayoutDirectionStore'; +import {renderHook} from '@testing-library/react'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +const {useStoreMock} = vi.hoisted(() => ({useStoreMock: vi.fn()})); + +vi.mock('@xyflow/react', () => ({ + useStore: (selector: (state: unknown) => unknown) => useStoreMock(selector), +})); + +interface BoxI { + left: number; + top: number; + width: number; +} + +const createBox = ({left, top, width}: BoxI) => ({ + getBoundingClientRect: () => ({left, right: left + width, top, width}), +}); + +const createDomNode = (boxes: Array, containerLeft = 0, containerWidth = 1000) => ({ + getBoundingClientRect: () => ({left: containerLeft, width: containerWidth}), + querySelectorAll: () => + boxes.map((box) => ({ + querySelector: (selector: string) => (selector === '[data-node-box]' ? createBox(box) : null), + })), +}); + +const createDomNodeWithoutBoxes = () => ({ + getBoundingClientRect: () => ({left: 0, width: 1000}), + querySelectorAll: () => [{querySelector: () => null}], +}); + +const mockStore = (domNode: unknown) => { + useStoreMock.mockImplementation((selector: (state: never) => unknown) => + selector({ + domNode, + nodeLookup: new Map([['node_1', {position: {x: 10}}]]), + transform: [0, 0, 1], + width: 1000, + } as never) + ); +}; + +describe('useFlowCenterOffset', () => { + beforeEach(() => { + vi.clearAllMocks(); + + useLayoutDirectionStore.setState({layoutDirection: 'TB'}); + }); + + it('is zero before the flow has a container', () => { + mockStore(null); + + const {result} = renderHook(() => useFlowCenterOffset()); + + expect(result.current).toBe(0); + }); + + it('is zero when no node renders a box', () => { + mockStore(createDomNodeWithoutBoxes()); + + const {result} = renderHook(() => useFlowCenterOffset()); + + expect(result.current).toBe(0); + }); + + it('measures the topmost box top to bottom', () => { + mockStore( + createDomNode([ + {left: 600, top: 100, width: 100}, + {left: 200, top: 20, width: 100}, + ]) + ); + + const {result} = renderHook(() => useFlowCenterOffset()); + + expect(result.current).toBe(-250); + }); + + it('measures the whole row left to right', () => { + useLayoutDirectionStore.setState({layoutDirection: 'LR'}); + + mockStore( + createDomNode([ + {left: 600, top: 100, width: 100}, + {left: 200, top: 20, width: 100}, + ]) + ); + + const {result} = renderHook(() => useFlowCenterOffset()); + + expect(result.current).toBe(-50); + }); +}); diff --git a/client/src/pages/platform/workflow-editor/hooks/tests/useNodeClick.test.ts b/client/src/pages/platform/workflow-editor/hooks/tests/useNodeClick.test.ts index 1272cdb8790..fcdcbc95662 100644 --- a/client/src/pages/platform/workflow-editor/hooks/tests/useNodeClick.test.ts +++ b/client/src/pages/platform/workflow-editor/hooks/tests/useNodeClick.test.ts @@ -14,10 +14,20 @@ vi.mock('../../cluster-element-editor/stores/useClusterElementsDataStore', () => })); vi.mock('../../stores/useWorkflowEditorStore', () => ({ - default: () => ({ - clusterElementsCanvasOpen: false, - setClusterElementsCanvasOpen: vi.fn(), - }), + default: Object.assign( + () => ({ + clusterElementsCanvasOpen: false, + setClusterElementsCanvasOpen: vi.fn(), + }), + { + getState: () => ({ + clusterElementsCanvasOpen: false, + setClusterElementsCanvasOpen: vi.fn(), + }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), })); vi.mock('@/pages/platform/workflow-editor/stores/useDataPillPanelStore', () => ({ diff --git a/client/src/pages/platform/workflow-editor/hooks/tests/useOverlayPanelsViewport.test.ts b/client/src/pages/platform/workflow-editor/hooks/tests/useOverlayPanelsViewport.test.ts new file mode 100644 index 00000000000..bbf320debbe --- /dev/null +++ b/client/src/pages/platform/workflow-editor/hooks/tests/useOverlayPanelsViewport.test.ts @@ -0,0 +1,233 @@ +import useOverlayPanelsViewport, { + OVERLAY_PAN_DURATION, +} from '@/pages/platform/workflow-editor/hooks/useOverlayPanelsViewport'; +import useDataPillPanelStore from '@/pages/platform/workflow-editor/stores/useDataPillPanelStore'; +import useRightSidebarStore from '@/pages/platform/workflow-editor/stores/useRightSidebarStore'; +import useWorkflowEditorStore from '@/pages/platform/workflow-editor/stores/useWorkflowEditorStore'; +import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore'; +import useWorkflowNodeDetailsPanelStore from '@/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore'; +import useWorkflowTestChatStore from '@/pages/platform/workflow-editor/stores/useWorkflowTestChatStore'; +import {DATA_PILL_PANEL_WIDTH, NODE_DETAILS_PANEL_WIDTH, WORKFLOW_NODES_SIDEBAR_WIDTH} from '@/shared/constants'; +import {act, renderHook} from '@testing-library/react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +interface ViewportI { + x: number; + y: number; + zoom: number; +} + +const {setViewportMock, storeState} = vi.hoisted(() => ({ + setViewportMock: vi.fn<(viewport: ViewportI, options?: {duration?: number}) => Promise>(() => + Promise.resolve(true) + ), + storeState: { + transform: [0, 40, 1] as [number, number, number], + }, +})); + +vi.mock('@xyflow/react', () => ({ + useReactFlow: () => ({setViewport: setViewportMock}), + useStoreApi: () => ({getState: () => storeState}), +})); + +const SIDEBAR_OFFSET = -WORKFLOW_NODES_SIDEBAR_WIDTH / 2; +const DETAILS_OFFSET = -NODE_DETAILS_PANEL_WIDTH / 2; +const DATA_PILL_OFFSET = -DATA_PILL_PANEL_WIDTH / 2; + +function lastViewport() { + const calls = setViewportMock.mock.calls; + + return calls[calls.length - 1]; +} + +function settleAt(x: number) { + act(() => { + vi.advanceTimersByTime(OVERLAY_PAN_DURATION); + }); + + storeState.transform = [x, 40, 1]; +} + +describe('useOverlayPanelsViewport', () => { + beforeEach(() => { + vi.useFakeTimers(); + setViewportMock.mockClear(); + + storeState.transform = [0, 40, 1]; + + useRightSidebarStore.setState({rightSidebarOpen: false}); + useWorkflowIssuesStore.setState({issuesSidebarOpen: false}); + useWorkflowTestChatStore.setState({workflowTestChatPanelOpen: false}); + useDataPillPanelStore.setState({dataPillPanelOpen: false}); + useWorkflowEditorStore.setState({clusterElementsCanvasOpen: false}); + useWorkflowNodeDetailsPanelStore.setState({workflowNodeDetailsPanelOpen: false}); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('leaves the viewport alone when nothing is open', () => { + renderHook(() => useOverlayPanelsViewport({enabled: true})); + + expect(setViewportMock).not.toHaveBeenCalled(); + }); + + it('applies an overlay that is already open on mount without animating', () => { + useRightSidebarStore.setState({rightSidebarOpen: true}); + + const {result} = renderHook(() => useOverlayPanelsViewport({enabled: true})); + + expect(setViewportMock).toHaveBeenCalledTimes(1); + expect(lastViewport()).toEqual([{x: SIDEBAR_OFFSET, y: 40, zoom: 1}]); + expect(result.current.getViewportOffsetX()).toBe(SIDEBAR_OFFSET); + }); + + it('re-centres by half the sidebar width and pans back on close', () => { + renderHook(() => useOverlayPanelsViewport({enabled: true})); + + act(() => { + useRightSidebarStore.setState({rightSidebarOpen: true}); + }); + + const [viewport, options] = lastViewport(); + + expect(viewport).toEqual({x: SIDEBAR_OFFSET, y: 40, zoom: 1}); + expect(options).toMatchObject({duration: OVERLAY_PAN_DURATION}); + + settleAt(SIDEBAR_OFFSET); + + act(() => { + useRightSidebarStore.setState({rightSidebarOpen: false}); + }); + + expect(setViewportMock).toHaveBeenCalledTimes(2); + expect(lastViewport()[0]).toEqual({x: 0, y: 40, zoom: 1}); + }); + + it('moves the flow left by half the node details panel width and back on close', () => { + const {result} = renderHook(() => useOverlayPanelsViewport({enabled: true})); + + act(() => { + useWorkflowNodeDetailsPanelStore.setState({workflowNodeDetailsPanelOpen: true}); + }); + + expect(lastViewport()[0]).toEqual({x: DETAILS_OFFSET, y: 40, zoom: 1}); + expect(result.current.getViewportOffsetX()).toBe(DETAILS_OFFSET); + + settleAt(DETAILS_OFFSET); + + act(() => { + useWorkflowNodeDetailsPanelStore.setState({workflowNodeDetailsPanelOpen: false}); + }); + + expect(lastViewport()[0]).toEqual({x: 0, y: 40, zoom: 1}); + expect(result.current.getViewportOffsetX()).toBe(0); + }); + + it('pushes further for the data pill panel and slides back when it closes', () => { + useWorkflowNodeDetailsPanelStore.setState({workflowNodeDetailsPanelOpen: true}); + + renderHook(() => useOverlayPanelsViewport({enabled: true})); + + storeState.transform = [DETAILS_OFFSET, 40, 1]; + + act(() => { + useDataPillPanelStore.setState({dataPillPanelOpen: true}); + }); + + expect(lastViewport()[0]).toEqual({x: DETAILS_OFFSET + DATA_PILL_OFFSET, y: 40, zoom: 1}); + + settleAt(DETAILS_OFFSET + DATA_PILL_OFFSET); + + act(() => { + useDataPillPanelStore.setState({dataPillPanelOpen: false}); + }); + + expect(lastViewport()[0]).toEqual({x: DETAILS_OFFSET, y: 40, zoom: 1}); + }); + + it('keeps a user pan made between toggles', () => { + renderHook(() => useOverlayPanelsViewport({enabled: true})); + + act(() => { + useWorkflowIssuesStore.setState({issuesSidebarOpen: true}); + }); + + settleAt(SIDEBAR_OFFSET + 500); + + act(() => { + useWorkflowIssuesStore.setState({issuesSidebarOpen: false}); + }); + + expect(lastViewport()[0].x).toBe(500); + }); + + it('combines the sidebar release and the details pan on the issues-to-details handoff', () => { + useWorkflowIssuesStore.setState({issuesSidebarOpen: true}); + + renderHook(() => useOverlayPanelsViewport({enabled: true})); + + storeState.transform = [SIDEBAR_OFFSET, 40, 1]; + setViewportMock.mockClear(); + + act(() => { + useWorkflowIssuesStore.setState({issuesSidebarOpen: false}); + useWorkflowNodeDetailsPanelStore.setState({workflowNodeDetailsPanelOpen: true}); + }); + + expect(setViewportMock).toHaveBeenCalledTimes(1); + expect(lastViewport()[0]).toEqual({x: DETAILS_OFFSET, y: 40, zoom: 1}); + + settleAt(DETAILS_OFFSET); + + act(() => { + useWorkflowNodeDetailsPanelStore.setState({workflowNodeDetailsPanelOpen: false}); + }); + + expect(lastViewport()[0]).toEqual({x: 0, y: 40, zoom: 1}); + }); + + it('continues from the pending target when a toggle interrupts an animation', () => { + renderHook(() => useOverlayPanelsViewport({enabled: true})); + + act(() => { + useRightSidebarStore.setState({rightSidebarOpen: true}); + }); + + storeState.transform = [-50, 40, 1]; + + act(() => { + useRightSidebarStore.setState({rightSidebarOpen: false}); + }); + + expect(lastViewport()[0]).toEqual({x: 0, y: 40, zoom: 1}); + }); + + it('does nothing while disabled', () => { + useRightSidebarStore.setState({rightSidebarOpen: true}); + + renderHook(() => useOverlayPanelsViewport({enabled: false})); + + expect(setViewportMock).not.toHaveBeenCalled(); + }); + + it('waits while the cluster elements canvas covers the graph and reconciles once it closes', () => { + useWorkflowEditorStore.setState({clusterElementsCanvasOpen: true}); + + renderHook(() => useOverlayPanelsViewport({enabled: true})); + + act(() => { + useRightSidebarStore.setState({rightSidebarOpen: true}); + }); + + expect(setViewportMock).not.toHaveBeenCalled(); + + act(() => { + useWorkflowEditorStore.setState({clusterElementsCanvasOpen: false}); + }); + + expect(lastViewport()).toEqual([{x: SIDEBAR_OFFSET, y: 40, zoom: 1}]); + }); +}); diff --git a/client/src/pages/platform/workflow-editor/hooks/tests/useWorkflowIssuesSweep.test.ts b/client/src/pages/platform/workflow-editor/hooks/tests/useWorkflowIssuesSweep.test.ts new file mode 100644 index 00000000000..f1ad002effb --- /dev/null +++ b/client/src/pages/platform/workflow-editor/hooks/tests/useWorkflowIssuesSweep.test.ts @@ -0,0 +1,60 @@ +import useWorkflowIssuesSweep from '@/pages/platform/workflow-editor/hooks/useWorkflowIssuesSweep'; +import useWorkflowDataStore from '@/pages/platform/workflow-editor/stores/useWorkflowDataStore'; +import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore'; +import {renderHook} from '@testing-library/react'; +import {beforeEach, describe, expect, it} from 'vitest'; + +describe('useWorkflowIssuesSweep', () => { + beforeEach(() => { + useWorkflowIssuesStore.setState({sweepIssues: []}); + }); + + it('reports nothing for a workflow whose references all resolve', () => { + useWorkflowDataStore.setState({ + workflow: { + inputs: [], + tasks: [{label: 'One', name: 'task_1', parameters: {}, type: 'acme/v1/one'}], + triggers: [], + }, + } as never); + + renderHook(() => useWorkflowIssuesSweep()); + + expect(useWorkflowIssuesStore.getState().sweepIssues).toEqual([]); + }); + + it('reports a reference to a node the workflow does not have', () => { + useWorkflowDataStore.setState({ + workflow: { + inputs: [], + tasks: [{label: 'One', name: 'task_1', parameters: {value: '${missing_1.id}'}, type: 'acme/v1/one'}], + triggers: [], + }, + } as never); + + renderHook(() => useWorkflowIssuesSweep()); + + const sweepIssues = useWorkflowIssuesStore.getState().sweepIssues; + + expect(sweepIssues).toHaveLength(1); + expect(sweepIssues[0].nodeName).toBe('task_1'); + expect(sweepIssues[0].kind).toBe('BROKEN_REFERENCE'); + }); + + it('reports a duplicate node name', () => { + useWorkflowDataStore.setState({ + workflow: { + inputs: [], + tasks: [ + {label: 'One', name: 'task_1', parameters: {}, type: 'acme/v1/one'}, + {label: 'Two', name: 'task_1', parameters: {}, type: 'acme/v1/two'}, + ], + triggers: [], + }, + } as never); + + renderHook(() => useWorkflowIssuesSweep()); + + expect(useWorkflowIssuesStore.getState().sweepIssues[0].kind).toBe('DUPLICATE_NODE_NAME'); + }); +}); diff --git a/client/src/pages/platform/workflow-editor/hooks/tests/useWorkflowIssuesValidation.test.ts b/client/src/pages/platform/workflow-editor/hooks/tests/useWorkflowIssuesValidation.test.ts new file mode 100644 index 00000000000..730ed5ce855 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/hooks/tests/useWorkflowIssuesValidation.test.ts @@ -0,0 +1,109 @@ +import {renderHook} from '@testing-library/react'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import useWorkflowIssuesStore from '../../stores/useWorkflowIssuesStore'; +import useWorkflowIssuesValidation from '../useWorkflowIssuesValidation'; + +const hoisted = vi.hoisted(() => ({ + queryResult: {data: undefined as unknown}, + queryVariables: [] as Array, + workflowState: {workflow: {definition: '{"tasks":[]}', id: 'wf-1'}}, +})); + +vi.mock('@/shared/middleware/graphql', () => ({ + useValidateWorkflowQuery: (variables: unknown) => { + hoisted.queryVariables.push(variables); + + return hoisted.queryResult; + }, +})); + +vi.mock('@/shared/stores/useEnvironmentStore', () => ({ + useEnvironmentStore: (selector: (state: {currentEnvironmentId: number}) => unknown) => + selector({currentEnvironmentId: 2}), +})); + +vi.mock('../../stores/useWorkflowDataStore', () => ({ + default: (selector: (state: typeof hoisted.workflowState) => unknown) => selector(hoisted.workflowState), +})); + +describe('useWorkflowIssuesValidation', () => { + beforeEach(() => { + hoisted.queryVariables = []; + hoisted.queryResult = {data: undefined}; + hoisted.workflowState = {workflow: {definition: '{"tasks":[]}', id: 'wf-1'}}; + useWorkflowIssuesStore.getState().reset(); + }); + + it('queries with the definition and the selected environment', () => { + renderHook(() => useWorkflowIssuesValidation()); + + expect(hoisted.queryVariables[0]).toEqual({environmentId: 2, workflowDefinition: '{"tasks":[]}'}); + }); + + it('stores node issues from the response as validator issues', () => { + hoisted.queryResult = { + data: { + validateWorkflow: { + errors: [], + nodeIssues: [ + { + kind: 'MISSING_RESOURCE', + message: 'gone', + nodeName: 'dataTable_1', + propertyPath: 'table', + severity: 'ERROR', + }, + ], + warnings: [], + }, + }, + }; + + renderHook(() => useWorkflowIssuesValidation()); + + expect(useWorkflowIssuesStore.getState().validatorIssues).toEqual([ + { + kind: 'MISSING_RESOURCE', + message: 'gone', + nodeName: 'dataTable_1', + propertyPath: 'table', + severity: 'ERROR', + source: 'VALIDATOR', + }, + ]); + }); + + it('clears live issues when the definition changes', () => { + const {rerender} = renderHook(() => useWorkflowIssuesValidation()); + + useWorkflowIssuesStore.getState().recordLookupFailure('dataTable_1', 'table', 'stale'); + + hoisted.workflowState = {workflow: {definition: '{"tasks":[{}]}', id: 'wf-1'}}; + + rerender(); + + expect(useWorkflowIssuesStore.getState().liveIssues).toEqual({}); + }); + + it('clears validator issues when the definition changes', () => { + const {rerender} = renderHook(() => useWorkflowIssuesValidation()); + + useWorkflowIssuesStore.getState().setValidatorIssues([ + { + kind: 'MISSING_RESOURCE', + message: 'gone', + nodeName: 'dataTable_1', + propertyPath: 'table', + severity: 'ERROR', + source: 'VALIDATOR', + }, + ]); + + hoisted.workflowState = {workflow: {definition: '{"tasks":[{}]}', id: 'wf-1'}}; + + rerender(); + + expect(useWorkflowIssuesStore.getState().validatorIssues).toEqual([]); + }); +}); diff --git a/client/src/pages/platform/workflow-editor/hooks/useDelayedUnmount.ts b/client/src/pages/platform/workflow-editor/hooks/useDelayedUnmount.ts new file mode 100644 index 00000000000..b47f42e913b --- /dev/null +++ b/client/src/pages/platform/workflow-editor/hooks/useDelayedUnmount.ts @@ -0,0 +1,42 @@ +import {useEffect, useState} from 'react'; + +export default function useDelayedUnmount(open: boolean, durationMs = 300): {mounted: boolean; visible: boolean} { + const [mounted, setMounted] = useState(open); + const [visible, setVisible] = useState(open); + + useEffect(() => { + let outerRafId: number | undefined; + let innerRafId: number | undefined; + let timerId: ReturnType | undefined; + + if (open) { + setMounted(true); + + outerRafId = requestAnimationFrame(() => { + innerRafId = requestAnimationFrame(() => { + setVisible(true); + }); + }); + } else { + setVisible(false); + + timerId = setTimeout(() => setMounted(false), durationMs); + } + + return () => { + if (outerRafId !== undefined) { + cancelAnimationFrame(outerRafId); + } + + if (innerRafId !== undefined) { + cancelAnimationFrame(innerRafId); + } + + if (timerId !== undefined) { + clearTimeout(timerId); + } + }; + }, [durationMs, open]); + + return {mounted, visible}; +} diff --git a/client/src/pages/platform/workflow-editor/hooks/useFlowCenterOffset.ts b/client/src/pages/platform/workflow-editor/hooks/useFlowCenterOffset.ts new file mode 100644 index 00000000000..daedd1931d8 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/hooks/useFlowCenterOffset.ts @@ -0,0 +1,72 @@ +import {useStore} from '@xyflow/react'; +import {useLayoutEffect, useState} from 'react'; + +import useLayoutDirectionStore from '../stores/useLayoutDirectionStore'; + +export default function useFlowCenterOffset(): number { + const [offset, setOffset] = useState(0); + + const layoutDirection = useLayoutDirectionStore((state) => state.layoutDirection); + + const containerWidth = useStore((state) => state.width); + const domNode = useStore((state) => state.domNode); + const transform = useStore((state) => state.transform.join()); + const nodePositions = useStore((state) => { + let positions = ''; + + for (const node of state.nodeLookup.values()) { + positions += `${node.position.x},`; + } + + return positions; + }); + + useLayoutEffect(() => { + if (!domNode) { + return; + } + + const nodeElements = domNode.querySelectorAll('.react-flow__node[data-id]'); + + let topBox: HTMLElement | undefined; + let topY = Number.POSITIVE_INFINITY; + let minLeft = Number.POSITIVE_INFINITY; + let maxRight = Number.NEGATIVE_INFINITY; + + for (const nodeElement of nodeElements) { + const box = nodeElement.querySelector('[data-node-box]'); + + if (!box) { + continue; + } + + const {left, right, top} = box.getBoundingClientRect(); + + minLeft = Math.min(minLeft, left); + maxRight = Math.max(maxRight, right); + + if (top < topY) { + topY = top; + topBox = box; + } + } + + if (!topBox) { + setOffset(0); + + return; + } + + const containerRect = domNode.getBoundingClientRect(); + const containerCenterX = containerRect.left + containerRect.width / 2; + + const flowCenterX = + layoutDirection === 'LR' + ? (minLeft + maxRight) / 2 + : topBox.getBoundingClientRect().left + topBox.getBoundingClientRect().width / 2; + + setOffset(Math.round(flowCenterX - containerCenterX)); + }, [containerWidth, domNode, layoutDirection, nodePositions, transform]); + + return offset; +} diff --git a/client/src/pages/platform/workflow-editor/hooks/useLayout.tsx b/client/src/pages/platform/workflow-editor/hooks/useLayout.tsx index 362d9a25710..d6c15ed4cf2 100644 --- a/client/src/pages/platform/workflow-editor/hooks/useLayout.tsx +++ b/client/src/pages/platform/workflow-editor/hooks/useLayout.tsx @@ -1,15 +1,12 @@ import { COPILOT_PANEL_WIDTH, - DATA_PILL_PANEL_WIDTH, EDGE_STYLES, FINAL_PLACEHOLDER_NODE_ID, LayoutDirectionType, - NODE_DETAILS_PANEL_WIDTH, ON_ERROR_WIRE_KEY_ERROR_BRANCH, ON_ERROR_WIRE_KEY_MAIN_BRANCH, PROJECT_LEFT_SIDEBAR_WIDTH, TASK_DISPATCHER_NAMES, - WORKFLOW_NODES_SIDEBAR_WIDTH, } from '@/shared/constants'; import { ComponentDefinitionBasic, @@ -24,13 +21,9 @@ import {useEffect, useMemo, useRef} from 'react'; import {useShallow} from 'zustand/react/shallow'; import {useStoreWithEqualityFn} from 'zustand/traditional'; -import useDataPillPanelStore from '../stores/useDataPillPanelStore'; import useLayoutDirectionStore from '../stores/useLayoutDirectionStore'; -import useRightSidebarStore from '../stores/useRightSidebarStore'; import useWorkflowDataStore from '../stores/useWorkflowDataStore'; import useWorkflowEditorStore from '../stores/useWorkflowEditorStore'; -import useWorkflowNodeDetailsPanelStore from '../stores/useWorkflowNodeDetailsPanelStore'; -import useWorkflowTestChatStore from '../stores/useWorkflowTestChatStore'; import animateNodePositions from '../utils/animateNodePositions'; import createBranchEdges from '../utils/createBranchEdges'; import createBranchNode from '../utils/createBranchNode'; @@ -175,12 +168,6 @@ export default function useLayout({ setSavedPositionCrossAxisShift: state.setSavedPositionCrossAxisShift, })) ); - const dataPillPanelOpen = useDataPillPanelStore((state) => state.dataPillPanelOpen); - const workflowNodeDetailsPanelOpen = useWorkflowNodeDetailsPanelStore( - (state) => state.workflowNodeDetailsPanelOpen - ); - const workflowTestChatPanelOpen = useWorkflowTestChatStore((state) => state.workflowTestChatPanelOpen); - const rightSidebarOpen = useRightSidebarStore((state) => state.rightSidebarOpen); const layoutResetCounter = useWorkflowDataStore((state) => state.layoutResetCounter); const cancelAnimationRef = useRef<(() => void) | null>(null); @@ -190,11 +177,7 @@ export default function useLayout({ const canvasWidthRef = useRef(canvasWidth); const canvasHeightRef = useRef(canvasHeight); const previousCopilotPanelOpenRef = useRef(undefined); - const previousDataPillPanelOpenRef = useRef(undefined); - const previousNodeDetailsPanelOpenRef = useRef(undefined); - const previousTestChatPanelOpenRef = useRef(undefined); const previousLeftSidebarOpenRef = useRef(undefined); - const previousRightSidebarOpenRef = useRef(undefined); canvasWidthRef.current = canvasWidth; canvasHeightRef.current = canvasHeight; @@ -640,11 +623,7 @@ export default function useLayout({ useEffect(() => { if (!useWorkflowDataStore.getState().isWorkflowLoaded) { previousCopilotPanelOpenRef.current = copilotPanelOpen; - previousDataPillPanelOpenRef.current = dataPillPanelOpen; - previousNodeDetailsPanelOpenRef.current = workflowNodeDetailsPanelOpen; - previousTestChatPanelOpenRef.current = workflowTestChatPanelOpen; previousLeftSidebarOpenRef.current = leftSidebarOpen; - previousRightSidebarOpenRef.current = rightSidebarOpen; return; } @@ -667,27 +646,6 @@ export default function useLayout({ widthDelta += copilotPanelOpen ? COPILOT_PANEL_WIDTH : -COPILOT_PANEL_WIDTH; } - if ( - previousNodeDetailsPanelOpenRef.current !== undefined && - previousNodeDetailsPanelOpenRef.current !== workflowNodeDetailsPanelOpen - ) { - widthDelta += workflowNodeDetailsPanelOpen ? NODE_DETAILS_PANEL_WIDTH : -NODE_DETAILS_PANEL_WIDTH; - } - - if ( - previousTestChatPanelOpenRef.current !== undefined && - previousTestChatPanelOpenRef.current !== workflowTestChatPanelOpen - ) { - widthDelta += workflowTestChatPanelOpen ? NODE_DETAILS_PANEL_WIDTH : -NODE_DETAILS_PANEL_WIDTH; - } - - if ( - previousDataPillPanelOpenRef.current !== undefined && - previousDataPillPanelOpenRef.current !== dataPillPanelOpen - ) { - widthDelta += dataPillPanelOpen ? DATA_PILL_PANEL_WIDTH : -DATA_PILL_PANEL_WIDTH; - } - if ( previousLeftSidebarOpenRef.current !== undefined && previousLeftSidebarOpenRef.current !== leftSidebarOpen @@ -695,31 +653,13 @@ export default function useLayout({ widthDelta += leftSidebarOpen ? PROJECT_LEFT_SIDEBAR_WIDTH : -PROJECT_LEFT_SIDEBAR_WIDTH; } - if ( - previousRightSidebarOpenRef.current !== undefined && - previousRightSidebarOpenRef.current !== rightSidebarOpen - ) { - widthDelta += rightSidebarOpen ? WORKFLOW_NODES_SIDEBAR_WIDTH : -WORKFLOW_NODES_SIDEBAR_WIDTH; - } + previousCopilotPanelOpenRef.current = copilotPanelOpen; + previousLeftSidebarOpenRef.current = leftSidebarOpen; if (widthDelta === 0) { - previousCopilotPanelOpenRef.current = copilotPanelOpen; - previousDataPillPanelOpenRef.current = dataPillPanelOpen; - previousNodeDetailsPanelOpenRef.current = workflowNodeDetailsPanelOpen; - previousTestChatPanelOpenRef.current = workflowTestChatPanelOpen; - previousLeftSidebarOpenRef.current = leftSidebarOpen; - previousRightSidebarOpenRef.current = rightSidebarOpen; - return; } - previousCopilotPanelOpenRef.current = copilotPanelOpen; - previousDataPillPanelOpenRef.current = dataPillPanelOpen; - previousNodeDetailsPanelOpenRef.current = workflowNodeDetailsPanelOpen; - previousTestChatPanelOpenRef.current = workflowTestChatPanelOpen; - previousLeftSidebarOpenRef.current = leftSidebarOpen; - previousRightSidebarOpenRef.current = rightSidebarOpen; - if (cancelAnimationRef.current) { cancelAnimationRef.current(); cancelAnimationRef.current = null; @@ -745,14 +685,7 @@ export default function useLayout({ })); cancelAnimationRef.current = animateNodePositions(currentNodes, shiftedNodes, updateNodes); - }, [ - copilotPanelOpen, - dataPillPanelOpen, - leftSidebarOpen, - rightSidebarOpen, - workflowNodeDetailsPanelOpen, - workflowTestChatPanelOpen, - ]); + }, [copilotPanelOpen, leftSidebarOpen]); useEffect(() => { if (useWorkflowDataStore.getState().isNodeDragging) { diff --git a/client/src/pages/platform/workflow-editor/hooks/useNodeClick.ts b/client/src/pages/platform/workflow-editor/hooks/useNodeClick.ts index 5eff85fdf2c..d47e990685f 100644 --- a/client/src/pages/platform/workflow-editor/hooks/useNodeClick.ts +++ b/client/src/pages/platform/workflow-editor/hooks/useNodeClick.ts @@ -1,6 +1,3 @@ -import useDataPillPanelStore from '@/pages/platform/workflow-editor/stores/useDataPillPanelStore'; -import useRightSidebarStore from '@/pages/platform/workflow-editor/stores/useRightSidebarStore'; -import useWorkflowTestChatStore from '@/pages/platform/workflow-editor/stores/useWorkflowTestChatStore'; import {NodeDataType, TabNameType} from '@/shared/types'; import {NodeProps} from '@xyflow/react'; import {useCallback} from 'react'; @@ -9,22 +6,9 @@ import {useShallow} from 'zustand/react/shallow'; import useClusterElementsDataStore from '../../cluster-element-editor/stores/useClusterElementsDataStore'; import useWorkflowDataStore from '../stores/useWorkflowDataStore'; import useWorkflowEditorStore from '../stores/useWorkflowEditorStore'; -import useWorkflowNodeDetailsPanelStore from '../stores/useWorkflowNodeDetailsPanelStore'; -import {getNodeLabel} from '../utils/getNodeLabel'; +import openNodeDetails from '../utils/openNodeDetails'; export default function useNodeClick(data: NodeDataType, id: NodeProps['id'], activeTab?: TabNameType) { - const {setActiveTab, setCurrentNode, setWorkflowNodeDetailsPanelOpen} = useWorkflowNodeDetailsPanelStore( - useShallow((state) => ({ - setActiveTab: state.setActiveTab, - setCurrentNode: state.setCurrentNode, - setWorkflowNodeDetailsPanelOpen: state.setWorkflowNodeDetailsPanelOpen, - })) - ); - - const setDataPillPanelOpen = useDataPillPanelStore((state) => state.setDataPillPanelOpen); - const setRightSidebarOpen = useRightSidebarStore((state) => state.setRightSidebarOpen); - const setWorkflowTestChatPanelOpen = useWorkflowTestChatStore((state) => state.setWorkflowTestChatPanelOpen); - const {nodes} = useWorkflowDataStore( useShallow((state) => ({ nodes: state.nodes, @@ -37,7 +21,7 @@ export default function useNodeClick(data: NodeDataType, id: NodeProps['id'], ac })) ); - const {clusterElementsCanvasOpen, setClusterElementsCanvasOpen} = useWorkflowEditorStore(); + const {clusterElementsCanvasOpen} = useWorkflowEditorStore(); return useCallback(() => { const clickedNode = nodes.find((node) => node.id === id); @@ -51,46 +35,6 @@ export default function useNodeClick(data: NodeDataType, id: NodeProps['id'], ac return; } - const {currentNode: existingCurrentNode, workflowNodeDetailsPanelOpen: isPanelOpen} = - useWorkflowNodeDetailsPanelStore.getState(); - - const isNodeAlreadyOpen = isPanelOpen && existingCurrentNode?.workflowNodeName === data.workflowNodeName; - - setRightSidebarOpen(false); - setWorkflowTestChatPanelOpen(false); - setActiveTab(activeTab ?? 'description'); - - if (!isNodeAlreadyOpen) { - setDataPillPanelOpen(false); - - const {workflow} = useWorkflowDataStore.getState(); - - setCurrentNode((previousCurrentNode) => ({ - ...data, - description: '', - displayConditions: previousCurrentNode?.displayConditions, - label: getNodeLabel({fallbackLabel: data.label, workflow, workflowNodeName: data.workflowNodeName}), - })); - - if (!!data.clusterRoot && !clusterElementsCanvasOpen) { - setClusterElementsCanvasOpen(true); - } - } - - setWorkflowNodeDetailsPanelOpen(true); - }, [ - data, - id, - nodes, - clusterElementsCanvasNodes, - clusterElementsCanvasOpen, - setDataPillPanelOpen, - setRightSidebarOpen, - setWorkflowTestChatPanelOpen, - setActiveTab, - activeTab, - setCurrentNode, - setClusterElementsCanvasOpen, - setWorkflowNodeDetailsPanelOpen, - ]); + openNodeDetails(data, activeTab); + }, [activeTab, clusterElementsCanvasNodes, clusterElementsCanvasOpen, data, id, nodes]); } diff --git a/client/src/pages/platform/workflow-editor/hooks/useNodeIssues.ts b/client/src/pages/platform/workflow-editor/hooks/useNodeIssues.ts new file mode 100644 index 00000000000..15b168f4948 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/hooks/useNodeIssues.ts @@ -0,0 +1,31 @@ +import {useMemo} from 'react'; + +import {WorkflowIssueSeverityType} from '../stores/useWorkflowIssuesStore'; +import useWorkflowIssues from './useWorkflowIssues'; + +export default function useNodeIssues( + nodeName: string, + clusterElement = false +): { + count: number; + severity?: WorkflowIssueSeverityType; + title?: string; +} { + const issues = useWorkflowIssues(); + + return useMemo(() => { + const nodeIssues = issues.filter( + (issue) => + issue.nodeName === nodeName || + (clusterElement && + !!issue.propertyPath && + (issue.propertyPath.startsWith(`${nodeName}.`) || issue.propertyPath.includes(`.${nodeName}.`))) + ); + + return { + count: nodeIssues.length, + severity: nodeIssues[0]?.severity, + title: nodeIssues.map((issue) => issue.message).join('\n') || undefined, + }; + }, [clusterElement, issues, nodeName]); +} diff --git a/client/src/pages/platform/workflow-editor/hooks/useOverlayPanelsViewport.ts b/client/src/pages/platform/workflow-editor/hooks/useOverlayPanelsViewport.ts new file mode 100644 index 00000000000..f0801799c5c --- /dev/null +++ b/client/src/pages/platform/workflow-editor/hooks/useOverlayPanelsViewport.ts @@ -0,0 +1,91 @@ +import useDataPillPanelStore from '@/pages/platform/workflow-editor/stores/useDataPillPanelStore'; +import useRightSidebarStore from '@/pages/platform/workflow-editor/stores/useRightSidebarStore'; +import useWorkflowEditorStore from '@/pages/platform/workflow-editor/stores/useWorkflowEditorStore'; +import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore'; +import useWorkflowNodeDetailsPanelStore from '@/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore'; +import useWorkflowTestChatStore from '@/pages/platform/workflow-editor/stores/useWorkflowTestChatStore'; +import {useReactFlow, useStoreApi} from '@xyflow/react'; +import {useCallback, useEffect, useRef} from 'react'; + +import {easeOutCubic} from '../utils/animateNodePositions'; +import {computeOverlayViewportOffset} from '../utils/overlayPanelViewport'; + +export const OVERLAY_PAN_DURATION = 300; + +interface UseOverlayPanelsViewportProps { + enabled: boolean; +} + +export default function useOverlayPanelsViewport({enabled}: UseOverlayPanelsViewportProps) { + const appliedOffsetRef = useRef(undefined); + const pendingTargetXRef = useRef(undefined); + const pendingTimeoutRef = useRef(undefined); + + const clusterElementsCanvasOpen = useWorkflowEditorStore((state) => state.clusterElementsCanvasOpen); + const dataPillPanelOpen = useDataPillPanelStore((state) => state.dataPillPanelOpen); + const issuesSidebarOpen = useWorkflowIssuesStore((state) => state.issuesSidebarOpen); + const rightSidebarOpen = useRightSidebarStore((state) => state.rightSidebarOpen); + const workflowTestChatPanelOpen = useWorkflowTestChatStore((state) => state.workflowTestChatPanelOpen); + const workflowNodeDetailsPanelOpen = useWorkflowNodeDetailsPanelStore( + (state) => state.workflowNodeDetailsPanelOpen + ); + + const storeApi = useStoreApi(); + const {setViewport} = useReactFlow(); + + const getViewportOffsetX = useCallback(() => appliedOffsetRef.current ?? 0, []); + + useEffect(() => { + if (!enabled || clusterElementsCanvasOpen) { + return; + } + + const [currentX, currentY, zoom] = storeApi.getState().transform; + + const offset = computeOverlayViewportOffset({ + dataPillPanelOpen, + issuesSidebarOpen, + rightSidebarOpen, + workflowNodeDetailsPanelOpen, + workflowTestChatPanelOpen, + }); + const previousOffset = appliedOffsetRef.current; + const baseX = pendingTargetXRef.current ?? currentX; + const targetX = baseX - (previousOffset ?? 0) + offset; + + appliedOffsetRef.current = offset; + + if (targetX === baseX) { + return; + } + + const viewport = {x: targetX, y: currentY, zoom}; + + if (previousOffset === undefined) { + setViewport(viewport); + + return; + } + + window.clearTimeout(pendingTimeoutRef.current); + + pendingTargetXRef.current = targetX; + pendingTimeoutRef.current = window.setTimeout(() => { + pendingTargetXRef.current = undefined; + }, OVERLAY_PAN_DURATION); + + setViewport(viewport, {duration: OVERLAY_PAN_DURATION, ease: easeOutCubic, interpolate: 'linear'}); + }, [ + clusterElementsCanvasOpen, + dataPillPanelOpen, + enabled, + issuesSidebarOpen, + rightSidebarOpen, + setViewport, + storeApi, + workflowNodeDetailsPanelOpen, + workflowTestChatPanelOpen, + ]); + + return {getViewportOffsetX}; +} diff --git a/client/src/pages/platform/workflow-editor/hooks/useWorkflowCodeEditorSheet.ts b/client/src/pages/platform/workflow-editor/hooks/useWorkflowCodeEditorSheet.ts index d9ba9bba711..3bd6a0e2fe8 100644 --- a/client/src/pages/platform/workflow-editor/hooks/useWorkflowCodeEditorSheet.ts +++ b/client/src/pages/platform/workflow-editor/hooks/useWorkflowCodeEditorSheet.ts @@ -11,8 +11,7 @@ import {useFeatureFlagsStore} from '@/shared/stores/useFeatureFlagsStore'; import {WorkflowDefinitionType} from '@/shared/types'; import {getTestWorkflowAttachRequest, getTestWorkflowStreamPostRequest} from '@/shared/util/testWorkflow-utils'; import {MarkerSeverity} from 'monaco-editor'; -import {Ref, useCallback, useEffect, useState} from 'react'; -import {PanelImperativeHandle, usePanelCallbackRef} from 'react-resizable-panels'; +import {useCallback, useEffect, useState} from 'react'; import {useShallow} from 'zustand/shallow'; import useWorkflowDataStore from '../stores/useWorkflowDataStore'; @@ -43,10 +42,12 @@ type UseWorkflowCodeEditorSheetReturnType = { handleWorkflowTestConfigurationDialog: (open: boolean) => void; hasErrors: boolean; projectName: string; - setErrorPanelRef: Ref; setErrorsAccordionOpen: (open: boolean) => void; + setWarningsAccordionOpen: (open: boolean) => void; showWorkflowTestConfigurationDialog: boolean; unsavedChangesAlertDialogOpen: boolean; + warnings: string[]; + warningsAccordionOpen: boolean; workflowIsRunning: boolean; workflowTestExecution: WorkflowTestExecution | undefined; }; @@ -66,6 +67,7 @@ const useWorkflowCodeEditorSheet = ({ const [definition, setDefinition] = useState(workflow.definition!); const [dirty, setDirty] = useState(false); const [errorsAccordionOpen, setErrorsAccordionOpen] = useState(false); + const [warningsAccordionOpen, setWarningsAccordionOpen] = useState(false); const [jobId, setJobId] = useState(null); const [showWorkflowTestConfigurationDialog, setShowWorkflowTestConfigurationDialog] = useState(false); const [unsavedChangesAlertDialogOpen, setUnsavedChangesAlertDialogOpen] = useState(false); @@ -108,8 +110,6 @@ const useWorkflowCodeEditorSheet = ({ }); const {updateWorkflowMutation} = useWorkflowEditor(); - const [errorPanelRef, setErrorPanelRef] = usePanelCallbackRef(); - const handleCopilotClick = useCallback(() => { const { context: currentContext, @@ -223,7 +223,7 @@ const useWorkflowCodeEditorSheet = ({ {enabled: !!definition} ); - const {errors} = validateWorkflowData?.validateWorkflow ?? {errors: [], warnings: []}; + const {errors, warnings} = validateWorkflowData?.validateWorkflow ?? {errors: [], warnings: []}; const handleValidate = useCallback( (newMarkers: editor.IMarkerData[]) => { @@ -262,18 +262,6 @@ const useWorkflowCodeEditorSheet = ({ setStreamRequest(getTestWorkflowAttachRequest({jobId})); }, [workflow.id, currentEnvironmentId, getPersistedJobId, setWorkflowIsRunning, setJobId, setStreamRequest]); - useEffect(() => { - if (!errorPanelRef) { - return; - } - - if (errorsAccordionOpen) { - errorPanelRef.resize('250px'); - } else { - errorPanelRef.collapse(); - } - }, [errorPanelRef, errorsAccordionOpen]); - return { copilotEnabled, copilotPanelOpen, @@ -294,10 +282,12 @@ const useWorkflowCodeEditorSheet = ({ handleWorkflowTestConfigurationDialog: setShowWorkflowTestConfigurationDialog, hasErrors, projectName, - setErrorPanelRef, setErrorsAccordionOpen, + setWarningsAccordionOpen, showWorkflowTestConfigurationDialog, unsavedChangesAlertDialogOpen, + warnings, + warningsAccordionOpen, workflowIsRunning, workflowTestExecution, }; diff --git a/client/src/pages/platform/workflow-editor/hooks/useWorkflowEditorCanvas.ts b/client/src/pages/platform/workflow-editor/hooks/useWorkflowEditorCanvas.ts index 91214ce67cc..68de2c505f1 100644 --- a/client/src/pages/platform/workflow-editor/hooks/useWorkflowEditorCanvas.ts +++ b/client/src/pages/platform/workflow-editor/hooks/useWorkflowEditorCanvas.ts @@ -1,17 +1,11 @@ -import useDataPillPanelStore from '@/pages/platform/workflow-editor/stores/useDataPillPanelStore'; -import useRightSidebarStore from '@/pages/platform/workflow-editor/stores/useRightSidebarStore'; import useWorkflowDataStore from '@/pages/platform/workflow-editor/stores/useWorkflowDataStore'; import useWorkflowEditorStore from '@/pages/platform/workflow-editor/stores/useWorkflowEditorStore'; -import useWorkflowNodeDetailsPanelStore from '@/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore'; -import useWorkflowTestChatStore from '@/pages/platform/workflow-editor/stores/useWorkflowTestChatStore'; import useCopilotPanelStore from '@/shared/components/copilot/stores/useCopilotPanelStore'; import { + CANVAS_TOP_OFFSET, COPILOT_PANEL_WIDTH, - DATA_PILL_PANEL_WIDTH, FINAL_PLACEHOLDER_NODE_ID, - NODE_DETAILS_PANEL_WIDTH, PROJECT_LEFT_SIDEBAR_WIDTH, - WORKFLOW_NODES_SIDEBAR_WIDTH, } from '@/shared/constants'; import { ComponentDefinitionBasic, @@ -29,6 +23,7 @@ import RoundedSmoothStepEdge from '../edges/RoundedSmoothStepEdge'; import WorkflowEdge from '../edges/WorkflowEdge'; import useHandleDrop from '../hooks/useHandleDrop'; import useLayout from '../hooks/useLayout'; +import useOverlayPanelsViewport from '../hooks/useOverlayPanelsViewport'; import AiAgentNode from '../nodes/AiAgentNode'; import PlaceholderNode from '../nodes/PlaceholderNode'; import ReadOnlyNode from '../nodes/ReadOnlyNode'; @@ -92,13 +87,7 @@ const useWorkflowEditorCanvas = ({ })) ); const copilotPanelOpen = useCopilotPanelStore((state) => state.copilotPanelOpen); - const dataPillPanelOpen = useDataPillPanelStore((state) => state.dataPillPanelOpen); - const rightSidebarOpen = useRightSidebarStore((state) => state.rightSidebarOpen); const resetWorkflowLayout = useWorkflowEditorStore((state) => state.resetWorkflowLayout); - const workflowNodeDetailsPanelOpen = useWorkflowNodeDetailsPanelStore( - (state) => state.workflowNodeDetailsPanelOpen - ); - const workflowTestChatPanelOpen = useWorkflowTestChatStore((state) => state.workflowTestChatPanelOpen); const {setViewport} = useReactFlow(); @@ -494,22 +483,10 @@ const useWorkflowEditorCanvas = ({ canvasWidth -= COPILOT_PANEL_WIDTH; } - if (dataPillPanelOpen) { - canvasWidth -= DATA_PILL_PANEL_WIDTH; - } - if (leftSidebarOpen) { canvasWidth -= PROJECT_LEFT_SIDEBAR_WIDTH; } - if (rightSidebarOpen) { - canvasWidth -= WORKFLOW_NODES_SIDEBAR_WIDTH; - } - - if (workflowNodeDetailsPanelOpen || workflowTestChatPanelOpen) { - canvasWidth -= NODE_DETAILS_PANEL_WIDTH; - } - const canvasHeight = window.innerHeight - 60; useEffect(() => { @@ -555,6 +532,8 @@ const useWorkflowEditorCanvas = ({ taskDispatcherDefinitions, }); + const {getViewportOffsetX} = useOverlayPanelsViewport({enabled: !readOnlyWorkflow}); + const workflowUuid = workflow.workflowUuid; useEffect(() => { @@ -578,8 +557,8 @@ const useWorkflowEditorCanvas = ({ setViewport( { - x: 0, - y: 0, + x: getViewportOffsetX(), + y: CANVAS_TOP_OFFSET, zoom: 1, }, { diff --git a/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssues.ts b/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssues.ts new file mode 100644 index 00000000000..f3d5d17dda4 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssues.ts @@ -0,0 +1,19 @@ +import {useMemo} from 'react'; +import {useShallow} from 'zustand/react/shallow'; + +import useWorkflowIssuesStore, {WorkflowIssueI, mergeWorkflowIssues} from '../stores/useWorkflowIssuesStore'; + +export default function useWorkflowIssues(): Array { + const {liveIssues, sweepIssues, validatorIssues} = useWorkflowIssuesStore( + useShallow((state) => ({ + liveIssues: state.liveIssues, + sweepIssues: state.sweepIssues, + validatorIssues: state.validatorIssues, + })) + ); + + return useMemo( + () => mergeWorkflowIssues(Object.values(liveIssues), validatorIssues, sweepIssues), + [liveIssues, sweepIssues, validatorIssues] + ); +} diff --git a/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssuesSweep.ts b/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssuesSweep.ts new file mode 100644 index 00000000000..867f90b00cd --- /dev/null +++ b/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssuesSweep.ts @@ -0,0 +1,16 @@ +import {useEffect} from 'react'; + +import useWorkflowDataStore from '../stores/useWorkflowDataStore'; +import useWorkflowIssuesStore from '../stores/useWorkflowIssuesStore'; +import collectWorkflowIssues from '../utils/collectWorkflowIssues'; + +export default function useWorkflowIssuesSweep(): void { + const workflow = useWorkflowDataStore((state) => state.workflow); + const setSweepIssues = useWorkflowIssuesStore((state) => state.setSweepIssues); + + useEffect(() => { + setSweepIssues( + collectWorkflowIssues({inputs: workflow.inputs, tasks: workflow.tasks, triggers: workflow.triggers}) + ); + }, [setSweepIssues, workflow.inputs, workflow.tasks, workflow.triggers]); +} diff --git a/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssuesValidation.ts b/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssuesValidation.ts new file mode 100644 index 00000000000..4d6b981cab9 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssuesValidation.ts @@ -0,0 +1,45 @@ +import {useValidateWorkflowQuery} from '@/shared/middleware/graphql'; +import {useEnvironmentStore} from '@/shared/stores/useEnvironmentStore'; +import {useEffect} from 'react'; +import {useShallow} from 'zustand/react/shallow'; + +import useWorkflowDataStore from '../stores/useWorkflowDataStore'; +import useWorkflowIssuesStore, {WorkflowIssueI} from '../stores/useWorkflowIssuesStore'; + +export default function useWorkflowIssuesValidation(): void { + const workflow = useWorkflowDataStore((state) => state.workflow); + const currentEnvironmentId = useEnvironmentStore((state) => state.currentEnvironmentId); + const {clearLiveIssues, setValidatorIssues} = useWorkflowIssuesStore( + useShallow((state) => ({ + clearLiveIssues: state.clearLiveIssues, + setValidatorIssues: state.setValidatorIssues, + })) + ); + + const {data} = useValidateWorkflowQuery( + {environmentId: currentEnvironmentId, workflowDefinition: workflow.definition!}, + {enabled: !!workflow.definition} + ); + + useEffect(() => { + clearLiveIssues(); + setValidatorIssues([]); + }, [clearLiveIssues, setValidatorIssues, workflow.definition]); + + useEffect(() => { + if (!data) { + return; + } + + const validatorIssues: Array = data.validateWorkflow.nodeIssues.map((nodeIssue) => ({ + kind: nodeIssue.kind, + message: nodeIssue.message, + nodeName: nodeIssue.nodeName, + propertyPath: nodeIssue.propertyPath ?? undefined, + severity: nodeIssue.severity, + source: 'VALIDATOR', + })); + + setValidatorIssues(validatorIssues); + }, [data, setValidatorIssues]); +} diff --git a/client/src/pages/platform/workflow-editor/hooks/useWorkflowLayout.ts b/client/src/pages/platform/workflow-editor/hooks/useWorkflowLayout.ts index a781b8d742f..021988b7750 100644 --- a/client/src/pages/platform/workflow-editor/hooks/useWorkflowLayout.ts +++ b/client/src/pages/platform/workflow-editor/hooks/useWorkflowLayout.ts @@ -3,9 +3,11 @@ import useDataPillPanelStore from '@/pages/platform/workflow-editor/stores/useDa import useRightSidebarStore from '@/pages/platform/workflow-editor/stores/useRightSidebarStore'; import useWorkflowDataStore from '@/pages/platform/workflow-editor/stores/useWorkflowDataStore'; import useWorkflowEditorStore from '@/pages/platform/workflow-editor/stores/useWorkflowEditorStore'; +import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore'; import useWorkflowNodeDetailsPanelStore from '@/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore'; import useWorkflowTestChatStore from '@/pages/platform/workflow-editor/stores/useWorkflowTestChatStore'; import filterWorkflowNodeOutputs from '@/pages/platform/workflow-editor/utils/filterWorkflowNodeOutputs'; +import openIssuesSidebar from '@/pages/platform/workflow-editor/utils/openIssuesSidebar'; import useCopilotPanelStore from '@/shared/components/copilot/stores/useCopilotPanelStore'; import {MODE, Source, useCopilotStore} from '@/shared/components/copilot/stores/useCopilotStore'; import {ComponentDefinitionBasic} from '@/shared/middleware/platform/configuration'; @@ -32,6 +34,12 @@ export const useWorkflowLayout = (includeComponents?: string[]) => { setRightSidebarOpen: state.setRightSidebarOpen, })) ); + const {issuesSidebarOpen, setIssuesSidebarOpen} = useWorkflowIssuesStore( + useShallow((state) => ({ + issuesSidebarOpen: state.issuesSidebarOpen, + setIssuesSidebarOpen: state.setIssuesSidebarOpen, + })) + ); const {workflow, workflowNodes} = useWorkflowDataStore( useShallow((state) => ({ workflow: state.workflow, @@ -132,11 +140,22 @@ export const useWorkflowLayout = (includeComponents?: string[]) => { } const handleComponentsAndFlowControlsClick = () => { + setIssuesSidebarOpen(false); setWorkflowNodeDetailsPanelOpen(false); setWorkflowTestChatPanelOpen(false); setRightSidebarOpen(!rightSidebarOpen); }; + const handleWorkflowIssuesClick = () => { + if (issuesSidebarOpen) { + setIssuesSidebarOpen(false); + + return; + } + + openIssuesSidebar(); + }; + const handleCopilotClick = () => { const {context: currentContext} = useCopilotStore.getState(); @@ -181,6 +200,7 @@ export const useWorkflowLayout = (includeComponents?: string[]) => { handleCopilotClick, handleWorkflowCodeEditorClick, handleWorkflowInputsClick, + handleWorkflowIssuesClick, handleWorkflowOutputsClick, isWorkflowNodeOutputsPending, previousComponentDefinitions, diff --git a/client/src/pages/platform/workflow-editor/nodes/AiAgentNode.tsx b/client/src/pages/platform/workflow-editor/nodes/AiAgentNode.tsx index 00a67a151e4..7c21a279d3e 100644 --- a/client/src/pages/platform/workflow-editor/nodes/AiAgentNode.tsx +++ b/client/src/pages/platform/workflow-editor/nodes/AiAgentNode.tsx @@ -4,6 +4,7 @@ import {Skeleton} from '@/components/ui/skeleton'; import {Tooltip, TooltipContent, TooltipTrigger} from '@/components/ui/tooltip'; import WorkflowNodeContextMenu from '@/pages/platform/workflow-editor/components/WorkflowNodeContextMenu'; import WorkflowNodeDropdownMenu from '@/pages/platform/workflow-editor/components/WorkflowNodeDropdownMenu'; +import WorkflowNodeIssueBadge from '@/pages/platform/workflow-editor/components/WorkflowNodeIssueBadge'; import {CLUSTER_ROOT_NODE_LABEL_WIDTH} from '@/shared/constants'; import {useGetWorkflowNodeDescriptionQuery} from '@/shared/queries/platform/workflowNodeDescriptions.queries'; import {useEnvironmentStore} from '@/shared/stores/useEnvironmentStore'; @@ -352,126 +353,132 @@ const AiAgentNode = ({data, id}: {data: NodeDataType; id: string}) => {
)} - { - if (!open) setInfoCardOpen(false); - }} - open={infoCardOpen} - > - - + + + event.preventDefault()} + onOpenAutoFocus={(event) => event.preventDefault()} + side="right" + > +
+

{nodeLabel}

+ +
+ + {workflowNodeDescription?.description ? ( +
+ ) : ( +

No description available.

)} - - - - event.preventDefault()} - onOpenAutoFocus={(event) => event.preventDefault()} - side="right" - > -
-

{nodeLabel}

- -
- - {workflowNodeDescription?.description ? ( -
- ) : ( -

No description available.

- )} - - + + +
{ expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); }); + + it('shows an issue badge when the issues store has an issue for this node', () => { + useWorkflowIssuesStore.getState().setValidatorIssues([ + { + kind: 'MISSING_REQUIRED', + message: 'Missing required property: model', + nodeName: 'approval_1', + severity: 'ERROR', + source: 'VALIDATOR', + }, + ]); + + renderNode(); + + expect(screen.getByLabelText('1 issue')).toHaveAttribute('title', 'Missing required property: model'); + + useWorkflowIssuesStore.getState().reset(); + }); + + it('shows no badge for a node without issues', () => { + renderNode(); + + expect(screen.queryByLabelText(/issue/)).not.toBeInTheDocument(); + }); }); diff --git a/client/src/pages/platform/workflow-editor/nodes/WorkflowNode.tsx b/client/src/pages/platform/workflow-editor/nodes/WorkflowNode.tsx index dd249fcce2d..23cb8c80950 100644 --- a/client/src/pages/platform/workflow-editor/nodes/WorkflowNode.tsx +++ b/client/src/pages/platform/workflow-editor/nodes/WorkflowNode.tsx @@ -2,6 +2,7 @@ import Button from '@/components/Button/Button'; import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover'; import WorkflowNodeContextMenu from '@/pages/platform/workflow-editor/components/WorkflowNodeContextMenu'; import WorkflowNodeDropdownMenu from '@/pages/platform/workflow-editor/components/WorkflowNodeDropdownMenu'; +import WorkflowNodeIssueBadge from '@/pages/platform/workflow-editor/components/WorkflowNodeIssueBadge'; import WorkflowNodesPopoverMenu from '@/pages/platform/workflow-editor/components/WorkflowNodesPopoverMenu'; import {useWorkflowEditor} from '@/pages/platform/workflow-editor/providers/workflowEditorProvider'; import {getNodeLabel} from '@/pages/platform/workflow-editor/utils/getNodeLabel'; @@ -162,123 +163,133 @@ const WorkflowNodeContent = forwardRef /> )} - { - if (!open) onInfoClose(); - }} - open={infoCardOpen} - > - - + + + {!isMainRootClusterElement && ( + event.preventDefault()} + onOpenAutoFocus={(event) => event.preventDefault()} + side="right" + > +
+

{nodeLabel}

- {(isMainRootClusterElement || isNestedClusterRoot) && ( +
+ + {nodeDescription ? (
- {!(isNestedClusterRoot && isRenaming) && ( - - {nodeLabel} - - )} - - {data.operationName && ( -
-                                            {data.operationName}
-                                        
- )} - - {isNestedClusterRoot && ( - - {data.workflowNodeName} - - )} -
+ className="flex" + dangerouslySetInnerHTML={{ + __html: sanitize(nodeDescription, { + allowedAttributes: { + div: ['class'], + table: ['class'], + td: ['class'], + tr: ['class'], + }, + }), + }} + /> + ) : ( +

No description available.

)} -
- - - - {!isMainRootClusterElement && ( - event.preventDefault()} - onOpenAutoFocus={(event) => event.preventDefault()} - side="right" - > -
-

{nodeLabel}

- -
- - {nodeDescription ? ( -
- ) : ( -

No description available.

- )} - - )} - + + )} + +
{isNestedClusterRoot && isRenaming && (
diff --git a/client/src/pages/platform/workflow-editor/providers/workflowEditorReadOnlyContext.ts b/client/src/pages/platform/workflow-editor/providers/workflowEditorReadOnlyContext.ts new file mode 100644 index 00000000000..720f26d6fc9 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/providers/workflowEditorReadOnlyContext.ts @@ -0,0 +1,7 @@ +import {createContext, useContext} from 'react'; + +export const WorkflowEditorReadOnlyContext = createContext(false); + +export function useWorkflowEditorReadOnly(): boolean { + return useContext(WorkflowEditorReadOnlyContext); +} diff --git a/client/src/pages/platform/workflow-editor/stores/tests/useWorkflowIssuesStore.test.ts b/client/src/pages/platform/workflow-editor/stores/tests/useWorkflowIssuesStore.test.ts new file mode 100644 index 00000000000..3ff78705d7c --- /dev/null +++ b/client/src/pages/platform/workflow-editor/stores/tests/useWorkflowIssuesStore.test.ts @@ -0,0 +1,139 @@ +import {beforeEach, describe, expect, it} from 'vitest'; + +import useWorkflowIssuesStore, { + WorkflowIssueI, + getWorkflowIssueKey, + mergeWorkflowIssues, +} from '../useWorkflowIssuesStore'; + +const issue = (overrides: Partial): WorkflowIssueI => ({ + kind: 'MISSING_REQUIRED', + message: 'm', + nodeName: 'node_1', + severity: 'ERROR', + source: 'VALIDATOR', + ...overrides, +}); + +describe('getWorkflowIssueKey', () => { + it('keys on node, property path and kind, falling back to the message when there is no property path', () => { + expect(getWorkflowIssueKey(issue({message: 'a', propertyPath: 'table'}))).toBe('node_1|table|MISSING_REQUIRED'); + expect(getWorkflowIssueKey(issue({message: 'b', propertyPath: 'table'}))).toBe('node_1|table|MISSING_REQUIRED'); + expect(getWorkflowIssueKey(issue({propertyPath: undefined}))).toBe('node_1|m|MISSING_REQUIRED'); + }); + + it('keeps two unclassified issues on the same node distinct when only their messages differ', () => { + const firstOtherIssue = issue({kind: 'OTHER', message: 'first problem', propertyPath: undefined}); + const secondOtherIssue = issue({kind: 'OTHER', message: 'second problem', propertyPath: undefined}); + + expect(getWorkflowIssueKey(firstOtherIssue)).not.toBe(getWorkflowIssueKey(secondOtherIssue)); + }); +}); + +describe('mergeWorkflowIssues', () => { + it('collapses identical keys with live over validator over sweep', () => { + const sweep = issue({ + kind: 'BROKEN_REFERENCE', + message: 'sweep', + propertyPath: 'python_1.diff', + source: 'SWEEP', + }); + const validator = issue({ + kind: 'BROKEN_REFERENCE', + message: 'validator', + propertyPath: 'python_1.diff', + source: 'VALIDATOR', + }); + + const merged = mergeWorkflowIssues([], [validator], [sweep]); + + expect(merged).toHaveLength(1); + expect(merged[0].message).toBe('validator'); + }); + + it('suppresses a validator MISSING_RESOURCE when a live LOOKUP_FAILED exists for the same property', () => { + const validator = issue({kind: 'MISSING_RESOURCE', message: 'not found', propertyPath: 'table'}); + const live = issue({kind: 'LOOKUP_FAILED', message: 'no primary key', propertyPath: 'table', source: 'LIVE'}); + + const merged = mergeWorkflowIssues([live], [validator], []); + + expect(merged).toHaveLength(1); + expect(merged[0].kind).toBe('LOOKUP_FAILED'); + expect(merged[0].message).toBe('no primary key'); + }); + + it('orders errors before warnings, then by node name', () => { + const merged = mergeWorkflowIssues( + [], + [ + issue({nodeName: 'b_1', severity: 'WARNING'}), + issue({nodeName: 'z_1', severity: 'ERROR'}), + issue({nodeName: 'a_1', severity: 'ERROR'}), + ], + [] + ); + + expect(merged.map((mergedIssue) => mergedIssue.nodeName)).toEqual(['a_1', 'z_1', 'b_1']); + }); + + it('keeps two unclassified issues on the same node when the server could not attribute a property path', () => { + const firstUnclassifiedIssue = issue({ + kind: 'OTHER', + message: 'first unclassified problem', + propertyPath: undefined, + }); + const secondUnclassifiedIssue = issue({ + kind: 'OTHER', + message: 'second unclassified problem', + propertyPath: undefined, + }); + + const merged = mergeWorkflowIssues([], [firstUnclassifiedIssue, secondUnclassifiedIssue], []); + + expect(merged).toHaveLength(2); + expect(merged.map((mergedIssue) => mergedIssue.message)).toEqual( + expect.arrayContaining(['first unclassified problem', 'second unclassified problem']) + ); + }); +}); + +describe('useWorkflowIssuesStore', () => { + beforeEach(() => { + useWorkflowIssuesStore.getState().reset(); + }); + + it('records, replaces and clears live lookup failures per node and property', () => { + const {clearLookupFailure, recordLookupFailure} = useWorkflowIssuesStore.getState(); + + recordLookupFailure('dataTable_2', 'table', 'first'); + recordLookupFailure('dataTable_2', 'table', 'second'); + recordLookupFailure('dataTable_2', 'id', 'other'); + + expect(Object.values(useWorkflowIssuesStore.getState().liveIssues)).toHaveLength(2); + expect(useWorkflowIssuesStore.getState().liveIssues['dataTable_2|table|LOOKUP_FAILED'].message).toBe('second'); + + clearLookupFailure('dataTable_2', 'table'); + + expect(Object.keys(useWorkflowIssuesStore.getState().liveIssues)).toEqual(['dataTable_2|id|LOOKUP_FAILED']); + }); + + it('replaces validator and sweep issues wholesale and resets everything', () => { + const {setIssuesSidebarOpen, setSweepIssues, setValidatorIssues} = useWorkflowIssuesStore.getState(); + + setValidatorIssues([issue({nodeName: 'v_1'})]); + setValidatorIssues([issue({nodeName: 'v_2'})]); + setSweepIssues([issue({nodeName: 's_1', source: 'SWEEP'})]); + setIssuesSidebarOpen(true); + + expect(useWorkflowIssuesStore.getState().validatorIssues.map((current) => current.nodeName)).toEqual(['v_2']); + + useWorkflowIssuesStore.getState().reset(); + + const state = useWorkflowIssuesStore.getState(); + + expect(state.validatorIssues).toEqual([]); + expect(state.sweepIssues).toEqual([]); + expect(state.liveIssues).toEqual({}); + expect(state.issuesSidebarOpen).toBe(false); + }); +}); diff --git a/client/src/pages/platform/workflow-editor/stores/useWorkflowIssuesStore.ts b/client/src/pages/platform/workflow-editor/stores/useWorkflowIssuesStore.ts new file mode 100644 index 00000000000..2869fe38a3f --- /dev/null +++ b/client/src/pages/platform/workflow-editor/stores/useWorkflowIssuesStore.ts @@ -0,0 +1,128 @@ +import {create} from 'zustand'; +import {devtools} from 'zustand/middleware'; + +export type WorkflowIssueKindType = + | 'BROKEN_REFERENCE' + | 'DUPLICATE_NODE_NAME' + | 'LOOKUP_FAILED' + | 'MISSING_CLUSTER_ELEMENT' + | 'MISSING_REQUIRED' + | 'MISSING_RESOURCE' + | 'OTHER' + | 'TASK_ORDER' + | 'TYPE_MISMATCH'; + +export type WorkflowIssueSeverityType = 'ERROR' | 'WARNING'; + +export type WorkflowIssueSourceType = 'LIVE' | 'SWEEP' | 'VALIDATOR'; + +export interface WorkflowIssueI { + kind: WorkflowIssueKindType; + message: string; + nodeName: string; + propertyPath?: string; + severity: WorkflowIssueSeverityType; + source: WorkflowIssueSourceType; +} + +const SEVERITY_ORDER: Record = {ERROR: 0, WARNING: 1}; + +export function getWorkflowIssueKey({ + kind, + message, + nodeName, + propertyPath, +}: Pick): string { + return `${nodeName}|${propertyPath ?? message}|${kind}`; +} + +export function mergeWorkflowIssues( + liveIssues: Array, + validatorIssues: Array, + sweepIssues: Array +): Array { + const issuesByKey = new Map(); + + for (const currentIssue of [...sweepIssues, ...validatorIssues, ...liveIssues]) { + issuesByKey.set(getWorkflowIssueKey(currentIssue), currentIssue); + } + + for (const liveIssue of liveIssues) { + if (liveIssue.kind === 'LOOKUP_FAILED') { + issuesByKey.delete( + getWorkflowIssueKey({ + kind: 'MISSING_RESOURCE', + message: '', + nodeName: liveIssue.nodeName, + propertyPath: liveIssue.propertyPath, + }) + ); + } + } + + return [...issuesByKey.values()].sort( + (first, second) => + SEVERITY_ORDER[first.severity] - SEVERITY_ORDER[second.severity] || + first.nodeName.localeCompare(second.nodeName) + ); +} + +interface WorkflowIssuesStateI { + clearLiveIssues: () => void; + clearLookupFailure: (nodeName: string, propertyPath?: string) => void; + issuesSidebarOpen: boolean; + liveIssues: Record; + recordLookupFailure: (nodeName: string, propertyPath: string | undefined, message: string) => void; + reset: () => void; + setIssuesSidebarOpen: (issuesSidebarOpen: boolean) => void; + setSweepIssues: (sweepIssues: Array) => void; + setValidatorIssues: (validatorIssues: Array) => void; + sweepIssues: Array; + validatorIssues: Array; +} + +const initialState = { + issuesSidebarOpen: false, + liveIssues: {}, + sweepIssues: [], + validatorIssues: [], +}; + +const useWorkflowIssuesStore = create()( + devtools( + (set) => ({ + ...initialState, + clearLiveIssues: () => set({liveIssues: {}}), + clearLookupFailure: (nodeName, propertyPath) => + set((state) => { + const liveIssues = {...state.liveIssues}; + + delete liveIssues[ + getWorkflowIssueKey({kind: 'LOOKUP_FAILED', message: '', nodeName, propertyPath}) + ]; + + return {liveIssues}; + }), + recordLookupFailure: (nodeName, propertyPath, message) => + set((state) => { + const liveIssue: WorkflowIssueI = { + kind: 'LOOKUP_FAILED', + message, + nodeName, + propertyPath, + severity: 'ERROR', + source: 'LIVE', + }; + + return {liveIssues: {...state.liveIssues, [getWorkflowIssueKey(liveIssue)]: liveIssue}}; + }), + reset: () => set({...initialState}), + setIssuesSidebarOpen: (issuesSidebarOpen) => set({issuesSidebarOpen}), + setSweepIssues: (sweepIssues) => set({sweepIssues}), + setValidatorIssues: (validatorIssues) => set({validatorIssues}), + }), + {name: 'workflow-issues'} + ) +); + +export default useWorkflowIssuesStore; diff --git a/client/src/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore.ts b/client/src/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore.ts index 1caeba6a7da..e71be65def3 100644 --- a/client/src/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore.ts +++ b/client/src/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore.ts @@ -36,6 +36,8 @@ interface WorkflowNodeDetailsPanelStoreI { reset: () => void; workflowNodeDetailsPanelOpen: boolean; + panelOpenedFromIssuesSidebar: boolean; + setPanelOpenedFromIssuesSidebar: (panelOpenedFromIssuesSidebar: boolean) => void; setWorkflowNodeDetailsPanelOpen: (workflowNodeDetailsPanelOpen: boolean) => void; } @@ -94,6 +96,10 @@ const useWorkflowNodeDetailsPanelStore = create( })), workflowNodeDetailsPanelOpen: false, + panelOpenedFromIssuesSidebar: false, + setPanelOpenedFromIssuesSidebar: (panelOpenedFromIssuesSidebar) => + set((state) => ({...state, panelOpenedFromIssuesSidebar})), + setWorkflowNodeDetailsPanelOpen: (workflowNodeDetailsPanelOpen) => set((state) => ({ ...state, diff --git a/client/src/pages/platform/workflow-editor/utils/animateNodePositions.ts b/client/src/pages/platform/workflow-editor/utils/animateNodePositions.ts index 63b97095b26..471dd5cd6dd 100644 --- a/client/src/pages/platform/workflow-editor/utils/animateNodePositions.ts +++ b/client/src/pages/platform/workflow-editor/utils/animateNodePositions.ts @@ -5,7 +5,7 @@ interface AnimationOptionsI { duration?: number; } -function easeOutCubic(t: number): number { +export function easeOutCubic(t: number): number { return 1 - Math.pow(1 - t, 3); } diff --git a/client/src/pages/platform/workflow-editor/utils/collectWorkflowIssues.test.ts b/client/src/pages/platform/workflow-editor/utils/collectWorkflowIssues.test.ts new file mode 100644 index 00000000000..3d29752ae27 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/utils/collectWorkflowIssues.test.ts @@ -0,0 +1,147 @@ +import {WorkflowTask, WorkflowTrigger} from '@/shared/middleware/platform/configuration'; +import {describe, expect, it} from 'vitest'; + +import collectWorkflowIssues from './collectWorkflowIssues'; + +const task = (name: string, parameters: Record = {}): WorkflowTask => + ({name, parameters, type: 'example/v1/action'}) as WorkflowTask; +const trigger = (name: string): WorkflowTrigger => ({name, type: 'manual/v1/manual'}) as WorkflowTrigger; + +describe('collectWorkflowIssues', () => { + it('returns nothing for a workflow whose references all resolve', () => { + const issues = collectWorkflowIssues({ + tasks: [task('python_1'), task('condition_1', {expression: '${python_1.diff} > 0'})], + triggers: [trigger('trigger_1')], + }); + + expect(issues).toEqual([]); + }); + + it('reports a reference whose root node is missing, keyed on the expression', () => { + const issues = collectWorkflowIssues({ + tasks: [task('condition_1', {expression: '${python_1.diff} > 0'})], + triggers: [trigger('trigger_1')], + }); + + expect(issues).toEqual([ + { + kind: 'BROKEN_REFERENCE', + message: '"python_1" is missing from the workflow (referenced as python_1.diff)', + nodeName: 'condition_1', + propertyPath: 'python_1.diff', + severity: 'ERROR', + source: 'SWEEP', + }, + ]); + }); + + it('accepts workflow inputs and trigger names as roots and ignores function expressions', () => { + const issues = collectWorkflowIssues({ + inputs: [{name: 'customerId'}], + tasks: [task('logger_1', {text: '${customerId} ${trigger_1.body} ${=1 + 1}'})], + triggers: [trigger('trigger_1')], + }); + + expect(issues).toEqual([]); + }); + + it('walks nested tasks and their parameters without treating nested task arrays as values', () => { + const issues = collectWorkflowIssues({ + tasks: [ + task('condition_1', { + caseTrue: [task('logger_1', {text: '${ghost_1.value}'})], + expression: 'true', + }), + ], + triggers: [trigger('trigger_1')], + }); + + expect(issues.map((issue) => [issue.nodeName, issue.propertyPath])).toEqual([['logger_1', 'ghost_1.value']]); + }); + + it('ignores function call expressions like now() while still reporting broken references', () => { + const issues = collectWorkflowIssues({ + tasks: [task('logger_1', {text: '${now()} ${ghost_1.value}'})], + triggers: [], + }); + + expect(issues).toEqual([ + { + kind: 'BROKEN_REFERENCE', + message: '"ghost_1" is missing from the workflow (referenced as ghost_1.value)', + nodeName: 'logger_1', + propertyPath: 'ghost_1.value', + severity: 'ERROR', + source: 'SWEEP', + }, + ]); + }); + + it('reports each missing reference once per node even when repeated', () => { + const issues = collectWorkflowIssues({ + tasks: [task('logger_1', {a: '${ghost_1.x}', b: '${ghost_1.x}'})], + triggers: [], + }); + + expect(issues).toHaveLength(1); + }); + + it('reports duplicate node names attributed to the duplicated name', () => { + const issues = collectWorkflowIssues({tasks: [task('logger_1'), task('logger_1')], triggers: []}); + + expect(issues).toEqual([ + { + kind: 'DUPLICATE_NODE_NAME', + message: 'Node names must be unique. Duplicate node name: logger_1', + nodeName: 'logger_1', + severity: 'ERROR', + source: 'SWEEP', + }, + ]); + }); + + it('pins the duplicate node name message to the literal the server validator produces', () => { + const issues = collectWorkflowIssues({tasks: [task('logger_1'), task('logger_1')], triggers: []}); + + expect(issues[0].message).toBe('Node names must be unique. Duplicate node name: logger_1'); + }); + + it('does not report a duplicate for a task the server lists both at the top level and inside its dispatcher', () => { + const issues = collectWorkflowIssues({ + tasks: [ + task('condition_1', { + caseTrue: [task('logger_1')], + expression: 'true', + }), + task('logger_1'), + ], + triggers: [], + }); + + expect(issues.filter((issue) => issue.kind === 'DUPLICATE_NODE_NAME')).toEqual([]); + }); + + it('reports a duplicate node name when the server lists the same name twice at the top level', () => { + const issues = collectWorkflowIssues({ + tasks: [ + task('condition_1', { + caseTrue: [task('logger_1')], + expression: 'true', + }), + task('logger_1'), + task('logger_1'), + ], + triggers: [], + }); + + expect(issues.filter((issue) => issue.kind === 'DUPLICATE_NODE_NAME')).toEqual([ + { + kind: 'DUPLICATE_NODE_NAME', + message: 'Node names must be unique. Duplicate node name: logger_1', + nodeName: 'logger_1', + severity: 'ERROR', + source: 'SWEEP', + }, + ]); + }); +}); diff --git a/client/src/pages/platform/workflow-editor/utils/collectWorkflowIssues.ts b/client/src/pages/platform/workflow-editor/utils/collectWorkflowIssues.ts new file mode 100644 index 00000000000..2ac457e4764 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/utils/collectWorkflowIssues.ts @@ -0,0 +1,146 @@ +import {WorkflowInput, WorkflowTask, WorkflowTrigger} from '@/shared/middleware/platform/configuration'; + +import {WorkflowIssueI} from '../stores/useWorkflowIssuesStore'; +import getDuplicateNodeNames from './getDuplicateNodeNames'; +import {forEachNestedTaskGroup} from './taskTraversalUtils'; + +const DATA_PILL_PATTERN = /\$\{([^}]+)}/g; +const REFERENCE_ROOT_PATTERN = /^([a-zA-Z_][a-zA-Z0-9_]*)/; +const NESTED_TASK_KEYS = new Set([ + 'branches', + 'caseFalse', + 'caseTrue', + 'cases', + 'default', + 'iteratee', + 'main-branch', + 'on-error-branch', + 'tasks', +]); + +interface CollectWorkflowIssuesProps { + inputs?: Array>; + tasks?: Array; + triggers?: Array; +} + +function collectNestedTasks( + tasks: Array, + collectedTasks: Array, + collectedNames: Set +): void { + for (const currentTask of tasks) { + if (!collectedNames.has(currentTask.name)) { + collectedNames.add(currentTask.name); + + collectedTasks.push(currentTask); + } + + if (currentTask.parameters) { + forEachNestedTaskGroup(currentTask.parameters as Record, (nestedTasks) => + collectNestedTasks(nestedTasks, collectedTasks, collectedNames) + ); + } + } +} + +function collectTasks(tasks: Array, collectedTasks: Array): void { + const collectedNames = new Set(); + + for (const currentTask of tasks) { + collectedTasks.push(currentTask); + collectedNames.add(currentTask.name); + } + + for (const currentTask of tasks) { + if (currentTask.parameters) { + forEachNestedTaskGroup(currentTask.parameters as Record, (nestedTasks) => + collectNestedTasks(nestedTasks, collectedTasks, collectedNames) + ); + } + } +} + +function collectExpressions(value: unknown, expressions: Array): void { + if (typeof value === 'string') { + for (const match of value.matchAll(DATA_PILL_PATTERN)) { + expressions.push(match[1]); + } + + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + collectExpressions(item, expressions); + } + + return; + } + + if (value && typeof value === 'object') { + for (const [key, nestedValue] of Object.entries(value)) { + if (!NESTED_TASK_KEYS.has(key)) { + collectExpressions(nestedValue, expressions); + } + } + } +} + +export default function collectWorkflowIssues({ + inputs = [], + tasks = [], + triggers = [], +}: CollectWorkflowIssuesProps): Array { + const allTasks: Array = []; + + collectTasks(tasks, allTasks); + + const knownNames = new Set([ + ...triggers.map((currentTrigger) => currentTrigger.name), + ...allTasks.map((currentTask) => currentTask.name), + ...inputs.map((input) => input.name), + ]); + + const issues: Array = getDuplicateNodeNames(allTasks, triggers).map((duplicateNodeName) => ({ + kind: 'DUPLICATE_NODE_NAME', + message: `Node names must be unique. Duplicate node name: ${duplicateNodeName}`, + nodeName: duplicateNodeName, + severity: 'ERROR', + source: 'SWEEP', + })); + + for (const currentTask of allTasks) { + const expressions: Array = []; + const reportedExpressions = new Set(); + + collectExpressions(currentTask.parameters, expressions); + + for (const expression of expressions) { + const rootMatch = REFERENCE_ROOT_PATTERN.exec(expression); + + if (!rootMatch || reportedExpressions.has(expression)) { + continue; + } + + const isFunctionCall = expression[rootMatch[0].length] === '('; + + if (isFunctionCall || knownNames.has(rootMatch[1])) { + continue; + } + + reportedExpressions.add(expression); + + issues.push({ + kind: 'BROKEN_REFERENCE', + message: `"${rootMatch[1]}" is missing from the workflow (referenced as ${expression})`, + nodeName: currentTask.name, + propertyPath: expression, + severity: 'ERROR', + source: 'SWEEP', + }); + } + } + + return issues; +} diff --git a/client/src/pages/platform/workflow-editor/utils/describeWorkflowIssueCounts.ts b/client/src/pages/platform/workflow-editor/utils/describeWorkflowIssueCounts.ts new file mode 100644 index 00000000000..c1d03a05fba --- /dev/null +++ b/client/src/pages/platform/workflow-editor/utils/describeWorkflowIssueCounts.ts @@ -0,0 +1,13 @@ +export default function describeWorkflowIssueCounts(errorCount: number, warningCount: number): string { + const parts: Array = []; + + if (errorCount > 0) { + parts.push(errorCount === 1 ? '1 error' : `${errorCount} errors`); + } + + if (warningCount > 0) { + parts.push(warningCount === 1 ? '1 warning' : `${warningCount} warnings`); + } + + return parts.join(', '); +} diff --git a/client/src/pages/platform/workflow-editor/utils/openIssuesSidebar.ts b/client/src/pages/platform/workflow-editor/utils/openIssuesSidebar.ts new file mode 100644 index 00000000000..a2c6a74cee2 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/utils/openIssuesSidebar.ts @@ -0,0 +1,11 @@ +import useRightSidebarStore from '@/pages/platform/workflow-editor/stores/useRightSidebarStore'; +import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore'; +import useWorkflowNodeDetailsPanelStore from '@/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore'; +import useWorkflowTestChatStore from '@/pages/platform/workflow-editor/stores/useWorkflowTestChatStore'; + +export default function openIssuesSidebar(): void { + useWorkflowNodeDetailsPanelStore.getState().setWorkflowNodeDetailsPanelOpen(false); + useWorkflowTestChatStore.getState().setWorkflowTestChatPanelOpen(false); + useRightSidebarStore.getState().setRightSidebarOpen(false); + useWorkflowIssuesStore.getState().setIssuesSidebarOpen(true); +} diff --git a/client/src/pages/platform/workflow-editor/utils/openNodeDetails.ts b/client/src/pages/platform/workflow-editor/utils/openNodeDetails.ts new file mode 100644 index 00000000000..7a4b2333e28 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/utils/openNodeDetails.ts @@ -0,0 +1,51 @@ +import useDataPillPanelStore from '@/pages/platform/workflow-editor/stores/useDataPillPanelStore'; +import useRightSidebarStore from '@/pages/platform/workflow-editor/stores/useRightSidebarStore'; +import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore'; +import useWorkflowTestChatStore from '@/pages/platform/workflow-editor/stores/useWorkflowTestChatStore'; +import {NodeDataType, TabNameType} from '@/shared/types'; + +import useWorkflowDataStore from '../stores/useWorkflowDataStore'; +import useWorkflowEditorStore from '../stores/useWorkflowEditorStore'; +import useWorkflowNodeDetailsPanelStore from '../stores/useWorkflowNodeDetailsPanelStore'; +import {getNodeLabel} from './getNodeLabel'; + +export default function openNodeDetails(data: NodeDataType, activeTab: TabNameType = 'description'): void { + const { + currentNode: existingCurrentNode, + setActiveTab, + setCurrentNode, + setWorkflowNodeDetailsPanelOpen, + workflowNodeDetailsPanelOpen: isPanelOpen, + } = useWorkflowNodeDetailsPanelStore.getState(); + const {clusterElementsCanvasOpen, setClusterElementsCanvasOpen} = useWorkflowEditorStore.getState(); + + const isNodeAlreadyOpen = isPanelOpen && existingCurrentNode?.workflowNodeName === data.workflowNodeName; + + const {issuesSidebarOpen, setIssuesSidebarOpen} = useWorkflowIssuesStore.getState(); + + useWorkflowNodeDetailsPanelStore.getState().setPanelOpenedFromIssuesSidebar(issuesSidebarOpen && !isPanelOpen); + + useRightSidebarStore.getState().setRightSidebarOpen(false); + useWorkflowTestChatStore.getState().setWorkflowTestChatPanelOpen(false); + setIssuesSidebarOpen(false); + setActiveTab(activeTab); + + if (!isNodeAlreadyOpen) { + useDataPillPanelStore.getState().setDataPillPanelOpen(false); + + const {workflow} = useWorkflowDataStore.getState(); + + setCurrentNode((previousCurrentNode) => ({ + ...data, + description: '', + displayConditions: previousCurrentNode?.displayConditions, + label: getNodeLabel({fallbackLabel: data.label, workflow, workflowNodeName: data.workflowNodeName}), + })); + + if (!!data.clusterRoot && !clusterElementsCanvasOpen) { + setClusterElementsCanvasOpen(true); + } + } + + setWorkflowNodeDetailsPanelOpen(true); +} diff --git a/client/src/pages/platform/workflow-editor/utils/overlayPanelViewport.test.ts b/client/src/pages/platform/workflow-editor/utils/overlayPanelViewport.test.ts new file mode 100644 index 00000000000..fd25b061f15 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/utils/overlayPanelViewport.test.ts @@ -0,0 +1,39 @@ +import {DATA_PILL_PANEL_WIDTH, NODE_DETAILS_PANEL_WIDTH, WORKFLOW_NODES_SIDEBAR_WIDTH} from '@/shared/constants'; +import {describe, expect, it} from 'vitest'; + +import {OverlayPanelsStateI, computeOverlayViewportOffset} from './overlayPanelViewport'; + +const allClosed: OverlayPanelsStateI = { + dataPillPanelOpen: false, + issuesSidebarOpen: false, + rightSidebarOpen: false, + workflowNodeDetailsPanelOpen: false, + workflowTestChatPanelOpen: false, +}; + +describe('computeOverlayViewportOffset', () => { + it('is zero when no overlay is open', () => { + expect(computeOverlayViewportOffset(allClosed)).toBe(0); + }); + + it('re-centres by half the width of each open overlay', () => { + expect(computeOverlayViewportOffset({...allClosed, rightSidebarOpen: true})).toBe( + -WORKFLOW_NODES_SIDEBAR_WIDTH / 2 + ); + expect(computeOverlayViewportOffset({...allClosed, issuesSidebarOpen: true})).toBe( + -WORKFLOW_NODES_SIDEBAR_WIDTH / 2 + ); + expect(computeOverlayViewportOffset({...allClosed, workflowTestChatPanelOpen: true})).toBe( + -NODE_DETAILS_PANEL_WIDTH / 2 + ); + expect(computeOverlayViewportOffset({...allClosed, workflowNodeDetailsPanelOpen: true})).toBe( + -NODE_DETAILS_PANEL_WIDTH / 2 + ); + }); + + it('adds the data pill panel on top of the node details panel', () => { + expect( + computeOverlayViewportOffset({...allClosed, dataPillPanelOpen: true, workflowNodeDetailsPanelOpen: true}) + ).toBe(-(NODE_DETAILS_PANEL_WIDTH + DATA_PILL_PANEL_WIDTH) / 2); + }); +}); diff --git a/client/src/pages/platform/workflow-editor/utils/overlayPanelViewport.ts b/client/src/pages/platform/workflow-editor/utils/overlayPanelViewport.ts new file mode 100644 index 00000000000..5e1719fbfac --- /dev/null +++ b/client/src/pages/platform/workflow-editor/utils/overlayPanelViewport.ts @@ -0,0 +1,41 @@ +import {DATA_PILL_PANEL_WIDTH, NODE_DETAILS_PANEL_WIDTH, WORKFLOW_NODES_SIDEBAR_WIDTH} from '@/shared/constants'; + +export interface OverlayPanelsStateI { + dataPillPanelOpen: boolean; + issuesSidebarOpen: boolean; + rightSidebarOpen: boolean; + workflowNodeDetailsPanelOpen: boolean; + workflowTestChatPanelOpen: boolean; +} + +export function computeOverlayViewportOffset({ + dataPillPanelOpen, + issuesSidebarOpen, + rightSidebarOpen, + workflowNodeDetailsPanelOpen, + workflowTestChatPanelOpen, +}: OverlayPanelsStateI): number { + let offset = 0; + + if (rightSidebarOpen) { + offset -= WORKFLOW_NODES_SIDEBAR_WIDTH / 2; + } + + if (issuesSidebarOpen) { + offset -= WORKFLOW_NODES_SIDEBAR_WIDTH / 2; + } + + if (workflowTestChatPanelOpen) { + offset -= NODE_DETAILS_PANEL_WIDTH / 2; + } + + if (workflowNodeDetailsPanelOpen) { + offset -= NODE_DETAILS_PANEL_WIDTH / 2; + } + + if (dataPillPanelOpen) { + offset -= DATA_PILL_PANEL_WIDTH / 2; + } + + return offset; +} diff --git a/client/src/pages/platform/workflow-editor/utils/tests/openNodeDetails.test.ts b/client/src/pages/platform/workflow-editor/utils/tests/openNodeDetails.test.ts new file mode 100644 index 00000000000..d48b06bc120 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/utils/tests/openNodeDetails.test.ts @@ -0,0 +1,90 @@ +import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore'; +import {NodeDataType} from '@/shared/types'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import openNodeDetails from '../openNodeDetails'; + +const hoisted = vi.hoisted(() => ({ + setActiveTab: vi.fn(), + setCurrentNode: vi.fn(), + setDataPillPanelOpen: vi.fn(), + setPanelOpenedFromIssuesSidebar: vi.fn(), + setRightSidebarOpen: vi.fn(), + setWorkflowNodeDetailsPanelOpen: vi.fn(), + setWorkflowTestChatPanelOpen: vi.fn(), +})); + +vi.mock('@/pages/platform/workflow-editor/stores/useDataPillPanelStore', () => ({ + default: {getState: () => ({setDataPillPanelOpen: hoisted.setDataPillPanelOpen})}, +})); + +vi.mock('@/pages/platform/workflow-editor/stores/useRightSidebarStore', () => ({ + default: {getState: () => ({setRightSidebarOpen: hoisted.setRightSidebarOpen})}, +})); + +vi.mock('@/pages/platform/workflow-editor/stores/useWorkflowTestChatStore', () => ({ + default: {getState: () => ({setWorkflowTestChatPanelOpen: hoisted.setWorkflowTestChatPanelOpen})}, +})); + +vi.mock('../../stores/useWorkflowDataStore', () => ({ + default: {getState: () => ({workflow: {tasks: [], triggers: []}})}, +})); + +vi.mock('../../stores/useWorkflowEditorStore', () => ({ + default: {getState: () => ({clusterElementsCanvasOpen: false, setClusterElementsCanvasOpen: vi.fn()})}, +})); + +vi.mock('../../stores/useWorkflowNodeDetailsPanelStore', () => ({ + default: { + getState: () => ({ + currentNode: undefined, + setActiveTab: hoisted.setActiveTab, + setCurrentNode: hoisted.setCurrentNode, + setPanelOpenedFromIssuesSidebar: hoisted.setPanelOpenedFromIssuesSidebar, + setWorkflowNodeDetailsPanelOpen: hoisted.setWorkflowNodeDetailsPanelOpen, + workflowNodeDetailsPanelOpen: false, + }), + }, +})); + +vi.mock('../getNodeLabel', () => ({getNodeLabel: () => 'Logger'})); + +const nodeData = {componentName: 'logger', name: 'logger_1', workflowNodeName: 'logger_1'} as NodeDataType; + +describe('openNodeDetails', () => { + beforeEach(() => { + useWorkflowIssuesStore.getState().reset(); + }); + + it('closes the workflow issues sidebar so it cannot stay open behind the details panel', () => { + useWorkflowIssuesStore.getState().setIssuesSidebarOpen(true); + + openNodeDetails(nodeData); + + expect(useWorkflowIssuesStore.getState().issuesSidebarOpen).toBe(false); + }); + + it('closes the other right docked panels', () => { + openNodeDetails(nodeData); + + expect(hoisted.setRightSidebarOpen).toHaveBeenCalledWith(false); + expect(hoisted.setWorkflowTestChatPanelOpen).toHaveBeenCalledWith(false); + expect(hoisted.setWorkflowNodeDetailsPanelOpen).toHaveBeenCalledWith(true); + }); + + it('marks a panel that replaces the issues sidebar so it does not animate in', () => { + useWorkflowIssuesStore.getState().setIssuesSidebarOpen(true); + + openNodeDetails({name: 'task_1', workflowNodeName: 'task_1'} as never, 'properties'); + + expect(hoisted.setPanelOpenedFromIssuesSidebar).toHaveBeenCalledWith(true); + }); + + it('leaves a panel opened from the canvas animating in as before', () => { + useWorkflowIssuesStore.getState().setIssuesSidebarOpen(false); + + openNodeDetails({name: 'task_1', workflowNodeName: 'task_1'} as never, 'properties'); + + expect(hoisted.setPanelOpenedFromIssuesSidebar).toHaveBeenCalledWith(false); + }); +}); diff --git a/client/src/shared/constants.tsx b/client/src/shared/constants.tsx index 3d21565da76..f3575ba42d7 100644 --- a/client/src/shared/constants.tsx +++ b/client/src/shared/constants.tsx @@ -226,10 +226,13 @@ export const TASK_DISPATCHER_DATA_KEY_MAP = { export const DEFAULT_CANVAS_WIDTH = 670; +export const CANVAS_TOP_OFFSET = 48; + export const COPILOT_PANEL_WIDTH = 450; export const DATA_PILL_PANEL_WIDTH = 400; export const NODE_DETAILS_PANEL_WIDTH = 460; export const WORKFLOW_NODES_SIDEBAR_WIDTH = 384; +export const ISSUES_SIDEBAR_EXIT_DURATION = 300; export const PROJECT_LEFT_SIDEBAR_WIDTH = 355; export const CANVAS_BACKGROUND_COLOR = '#E2E8F0'; diff --git a/client/src/shared/middleware/graphql-types.ts b/client/src/shared/middleware/graphql-types.ts index 04cc20a9acb..e1fc930ccef 100644 --- a/client/src/shared/middleware/graphql-types.ts +++ b/client/src/shared/middleware/graphql-types.ts @@ -2401,6 +2401,15 @@ export type MutationUpdateWorkspaceApiKeyArgs = { name: Scalars['String']['input']; }; +export type NodeValidationIssue = { + __typename?: 'NodeValidationIssue'; + kind: WorkflowIssueKind; + message: Scalars['String']['output']; + nodeName: Scalars['String']['output']; + propertyPath?: Maybe; + severity: WorkflowIssueSeverity; +}; + export type NullProperty = Property & { __typename?: 'NullProperty'; advancedOption?: Maybe; @@ -3412,11 +3421,13 @@ export type QueryUsersArgs = { export type QueryValidateWorkflowArgs = { + environmentId?: InputMaybe; workflow: Scalars['String']['input']; }; export type QueryValidateWorkflowByIdArgs = { + environmentId?: InputMaybe; workflowId: Scalars['String']['input']; }; @@ -3727,6 +3738,22 @@ export type WorkflowInfo = { label: Scalars['String']['output']; }; +export enum WorkflowIssueKind { + BrokenReference = 'BROKEN_REFERENCE', + DuplicateNodeName = 'DUPLICATE_NODE_NAME', + MissingClusterElement = 'MISSING_CLUSTER_ELEMENT', + MissingRequired = 'MISSING_REQUIRED', + MissingResource = 'MISSING_RESOURCE', + Other = 'OTHER', + TaskOrder = 'TASK_ORDER', + TypeMismatch = 'TYPE_MISMATCH' +} + +export enum WorkflowIssueSeverity { + Error = 'ERROR', + Warning = 'WARNING' +} + export type WorkflowNodeTestOutputResult = { __typename?: 'WorkflowNodeTestOutputResult'; id: Scalars['Long']['output']; @@ -3785,5 +3812,6 @@ export type WorkflowTrigger = { export type WorkflowValidationResult = { __typename?: 'WorkflowValidationResult'; errors: Array; + nodeIssues: Array; warnings: Array; }; diff --git a/client/src/shared/middleware/graphql.ts b/client/src/shared/middleware/graphql.ts index 0adabd3309f..8b48d9e2905 100644 --- a/client/src/shared/middleware/graphql.ts +++ b/client/src/shared/middleware/graphql.ts @@ -1703,10 +1703,11 @@ export type UpdateMcpToolMutation = { updateMcpTool: { id: string, name: string, export type ValidateWorkflowQueryVariables = Exact<{ workflowDefinition: string; + environmentId?: any; }>; -export type ValidateWorkflowQuery = { validateWorkflow: { errors: Array, warnings: Array } }; +export type ValidateWorkflowQuery = { validateWorkflow: { errors: Array, warnings: Array, nodeIssues: Array<{ nodeName: string, propertyPath: string | null, kind: Types.WorkflowIssueKind, severity: Types.WorkflowIssueSeverity, message: string }> } }; export type ValidateWorkflowByIdQueryVariables = Exact<{ workflowId: string; @@ -8264,10 +8265,17 @@ export const useUpdateMcpToolMutation = < )}; export const ValidateWorkflowDocument = new TypedDocumentString(` - query ValidateWorkflow($workflowDefinition: String!) { - validateWorkflow(workflow: $workflowDefinition) { + query ValidateWorkflow($workflowDefinition: String!, $environmentId: Long) { + validateWorkflow(workflow: $workflowDefinition, environmentId: $environmentId) { errors warnings + nodeIssues { + nodeName + propertyPath + kind + severity + message + } } } `); diff --git a/client/src/shared/util/recordWorkflowNodeLookupResult.ts b/client/src/shared/util/recordWorkflowNodeLookupResult.ts new file mode 100644 index 00000000000..2fa121b3d5d --- /dev/null +++ b/client/src/shared/util/recordWorkflowNodeLookupResult.ts @@ -0,0 +1,35 @@ +import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore'; +import {parseWorkflowNodeLookupUrl} from '@/shared/util/workflowNodeLookupUrl'; + +interface LookupResponseI { + clone: () => {json: () => Promise<{detail?: string; title?: string}>}; + status: number; + url: string; +} + +export default function recordWorkflowNodeLookupResult(response: LookupResponseI): boolean { + const workflowNodeLookup = parseWorkflowNodeLookupUrl(response.url); + + if (!workflowNodeLookup) { + return false; + } + + const {clearLookupFailure, recordLookupFailure} = useWorkflowIssuesStore.getState(); + const {nodeName, propertyName} = workflowNodeLookup; + + if (response.status >= 200 && response.status <= 299) { + clearLookupFailure(nodeName, propertyName); + + return true; + } + + const fallbackMessage = `Request failed with status ${response.status}`; + const clonedResponse = response.clone(); + + clonedResponse + .json() + .then((data) => recordLookupFailure(nodeName, propertyName, data.detail || data.title || fallbackMessage)) + .catch(() => recordLookupFailure(nodeName, propertyName, fallbackMessage)); + + return true; +} diff --git a/client/src/shared/util/tests/recordWorkflowNodeLookupResult.test.ts b/client/src/shared/util/tests/recordWorkflowNodeLookupResult.test.ts new file mode 100644 index 00000000000..1ed8e5ea137 --- /dev/null +++ b/client/src/shared/util/tests/recordWorkflowNodeLookupResult.test.ts @@ -0,0 +1,57 @@ +import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore'; +import recordWorkflowNodeLookupResult from '@/shared/util/recordWorkflowNodeLookupResult'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +const optionsUrl = 'http://localhost/internal/workflows/1/workflow-nodes/dataTable_1/options/table'; + +const createResponse = (status: number, body: {detail?: string; title?: string} = {}, rejects = false) => ({ + clone: () => ({ + json: () => (rejects ? Promise.reject(new Error('not json')) : Promise.resolve(body)), + }), + status, + url: optionsUrl, +}); + +describe('recordWorkflowNodeLookupResult', () => { + beforeEach(() => { + useWorkflowIssuesStore.setState({liveIssues: {}}); + }); + + it('ignores a url that is not a node lookup', () => { + const handled = recordWorkflowNodeLookupResult({ + clone: () => ({json: () => Promise.resolve({})}), + status: 500, + url: 'http://localhost/internal/workflows/1', + }); + + expect(handled).toBe(false); + expect(Object.keys(useWorkflowIssuesStore.getState().liveIssues)).toHaveLength(0); + }); + + it('clears a recorded failure once the lookup succeeds', () => { + const clearLookupFailure = vi.spyOn(useWorkflowIssuesStore.getState(), 'clearLookupFailure'); + + expect(recordWorkflowNodeLookupResult(createResponse(200))).toBe(true); + expect(clearLookupFailure).toHaveBeenCalledWith('dataTable_1', 'table'); + }); + + it('records the server reason for a failed lookup', async () => { + expect(recordWorkflowNodeLookupResult(createResponse(400, {detail: 'Table is gone'}))).toBe(true); + + await vi.waitFor(() => { + const issues = Object.values(useWorkflowIssuesStore.getState().liveIssues); + + expect(issues[0]?.message).toBe('Table is gone'); + }); + }); + + it('falls back to the status when the body carries no reason', async () => { + recordWorkflowNodeLookupResult(createResponse(503, {}, true)); + + await vi.waitFor(() => { + const issues = Object.values(useWorkflowIssuesStore.getState().liveIssues); + + expect(issues[0]?.message).toBe('Request failed with status 503'); + }); + }); +}); diff --git a/client/src/shared/util/tests/workflowNodeLookupUrl.test.ts b/client/src/shared/util/tests/workflowNodeLookupUrl.test.ts new file mode 100644 index 00000000000..53dc04e6127 --- /dev/null +++ b/client/src/shared/util/tests/workflowNodeLookupUrl.test.ts @@ -0,0 +1,81 @@ +import {describe, expect, it} from 'vitest'; + +import {parseWorkflowNodeLookupUrl} from '../workflowNodeLookupUrl'; + +describe('parseWorkflowNodeLookupUrl', () => { + it('parses a node options lookup', () => { + expect( + parseWorkflowNodeLookupUrl( + 'http://localhost/api/platform/internal/workflows/1052/workflow-nodes/dataTable_2/options/table?searchText=' + ) + ).toEqual({nodeName: 'dataTable_2', propertyName: 'table'}); + }); + + it('parses a dynamic properties lookup', () => { + expect( + parseWorkflowNodeLookupUrl('/internal/workflows/1052/workflow-nodes/dataTable_2/dynamic-properties/values') + ).toEqual({nodeName: 'dataTable_2', propertyName: 'values'}); + }); + + it('attributes a cluster element options lookup to the cluster element node', () => { + expect( + parseWorkflowNodeLookupUrl( + '/internal/workflows/1052/workflow-nodes/aiAgent_1/cluster-elements/model/openAi_1/options/model' + ) + ).toEqual({nodeName: 'openAi_1', propertyName: 'model'}); + }); + + it('attributes a cluster element dynamic properties lookup to the cluster element node', () => { + expect( + parseWorkflowNodeLookupUrl( + '/internal/workflows/1052/workflow-nodes/aiAgent_1/cluster-elements/model/openAi_1/dynamic-properties/model' + ) + ).toEqual({nodeName: 'openAi_1', propertyName: 'model'}); + }); + + it('parses a node wide output schema failure, which names no property', () => { + expect(parseWorkflowNodeLookupUrl('/internal/workflows/1052/workflow-nodes/dataTable_2/outputs')).toEqual({ + nodeName: 'dataTable_2', + }); + }); + + it('attributes a cluster element output schema failure to the cluster element node', () => { + expect( + parseWorkflowNodeLookupUrl( + '/internal/workflows/1052/workflow-nodes/aiAgent_1/cluster-elements/model/openAi_1/outputs' + ) + ).toEqual({nodeName: 'openAi_1'}); + }); + + it('does not match the workflow wide outputs endpoint', () => { + expect(parseWorkflowNodeLookupUrl('/internal/workflows/1052/outputs')).toBeUndefined(); + }); + + it('ignores unrelated urls', () => { + expect(parseWorkflowNodeLookupUrl('/internal/workflows/1052')).toBeUndefined(); + expect(parseWorkflowNodeLookupUrl('/graphql')).toBeUndefined(); + expect(parseWorkflowNodeLookupUrl('/internal/workflows/1052/workflow-nodes/dataTable_2')).toBeUndefined(); + }); + + it('attributes a workflow wide outputs lookup to the node named in the query', () => { + expect( + parseWorkflowNodeLookupUrl( + '/api/platform/internal/workflows/2035b93a/outputs?environmentId=0&lastWorkflowNodeName=dataTable_2' + ) + ).toEqual({nodeName: 'dataTable_2'}); + }); + + it('ignores a workflow wide outputs lookup that names no node', () => { + expect( + parseWorkflowNodeLookupUrl('/api/platform/internal/workflows/2035b93a/outputs?environmentId=0') + ).toBeUndefined(); + }); + + it('attributes a display conditions lookup to its node', () => { + expect( + parseWorkflowNodeLookupUrl( + '/api/platform/internal/workflows/2035b93a/workflow-nodes/condition_1/display-conditions?environmentId=0' + ) + ).toEqual({nodeName: 'condition_1'}); + }); +}); diff --git a/client/src/shared/util/workflowNodeLookupUrl.ts b/client/src/shared/util/workflowNodeLookupUrl.ts new file mode 100644 index 00000000000..708198e8c6d --- /dev/null +++ b/client/src/shared/util/workflowNodeLookupUrl.ts @@ -0,0 +1,35 @@ +const NODE_PATH = String.raw`/workflows/[^/]+/workflow-nodes/([^/]+)(?:/cluster-elements/[^/]+/([^/]+))?`; + +const PROPERTY_LOOKUP_URL_PATTERN = new RegExp(`${NODE_PATH}/(?:options|dynamic-properties)/([^/?]+)`); +const NODE_LOOKUP_URL_PATTERN = new RegExp(`${NODE_PATH}/(?:outputs|display-conditions)(?:[/?]|$)`); +const WORKFLOW_OUTPUTS_URL_PATTERN = /\/workflows\/[^/]+\/outputs(?:[/?]|$)/; +const LAST_WORKFLOW_NODE_NAME_PATTERN = /[?&]lastWorkflowNodeName=([^&]+)/; + +export interface WorkflowNodeLookupI { + nodeName: string; + propertyName?: string; +} + +export function parseWorkflowNodeLookupUrl(url: string): WorkflowNodeLookupI | undefined { + const propertyMatch = PROPERTY_LOOKUP_URL_PATTERN.exec(url); + + if (propertyMatch) { + return {nodeName: propertyMatch[2] ?? propertyMatch[1], propertyName: propertyMatch[3]}; + } + + const nodeMatch = NODE_LOOKUP_URL_PATTERN.exec(url); + + if (nodeMatch) { + return {nodeName: nodeMatch[2] ?? nodeMatch[1]}; + } + + if (WORKFLOW_OUTPUTS_URL_PATTERN.test(url)) { + const lastNodeNameMatch = LAST_WORKFLOW_NODE_NAME_PATTERN.exec(url); + + if (lastNodeNameMatch) { + return {nodeName: decodeURIComponent(lastNodeNameMatch[1])}; + } + } + + return undefined; +} diff --git a/sdks/backend/java/component-api/src/main/java/com/bytechef/component/definition/ComponentDsl.java b/sdks/backend/java/component-api/src/main/java/com/bytechef/component/definition/ComponentDsl.java index 9cf235d411e..a44911d41be 100644 --- a/sdks/backend/java/component-api/src/main/java/com/bytechef/component/definition/ComponentDsl.java +++ b/sdks/backend/java/component-api/src/main/java/com/bytechef/component/definition/ComponentDsl.java @@ -3119,6 +3119,10 @@ public M expressionEnabled(boolean expressionEnabled) { return (M) this; } + public M resourceReference(ResourceType resourceType) { + return metadata(RESOURCE_REFERENCE_METADATA_KEY, resourceType.name()); + } + @SuppressWarnings("unchecked") public M hidden(boolean hidden) { this.hidden = hidden; diff --git a/sdks/backend/java/component-api/src/test/java/com/bytechef/component/definition/ComponentDslResourceReferenceTest.java b/sdks/backend/java/component-api/src/test/java/com/bytechef/component/definition/ComponentDslResourceReferenceTest.java new file mode 100644 index 00000000000..f3c07ada02e --- /dev/null +++ b/sdks/backend/java/component-api/src/test/java/com/bytechef/component/definition/ComponentDslResourceReferenceTest.java @@ -0,0 +1,47 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.component.definition; + +import static com.bytechef.component.definition.ComponentDsl.string; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import com.bytechef.definition.BaseProperty; +import org.junit.jupiter.api.Test; + +/** + * @author Ivica Cardic + */ +class ComponentDslResourceReferenceTest { + + @Test + void resourceReferenceIsStoredAsMetadata() { + ComponentDsl.ModifiableStringProperty property = string("table") + .resourceReference(BaseProperty.ResourceType.DATA_TABLE); + + assertEquals("DATA_TABLE", property.getMetadata() + .get(BaseProperty.RESOURCE_REFERENCE_METADATA_KEY)); + } + + @Test + void propertyWithoutResourceReferenceHasNoMetadataEntry() { + ComponentDsl.ModifiableStringProperty property = string("table"); + + assertFalse(property.getMetadata() + .containsKey(BaseProperty.RESOURCE_REFERENCE_METADATA_KEY)); + } +} diff --git a/sdks/backend/java/definition-api/src/main/java/com/bytechef/definition/BaseProperty.java b/sdks/backend/java/definition-api/src/main/java/com/bytechef/definition/BaseProperty.java index 3d78f9b862e..fff15c76079 100644 --- a/sdks/backend/java/definition-api/src/main/java/com/bytechef/definition/BaseProperty.java +++ b/sdks/backend/java/definition-api/src/main/java/com/bytechef/definition/BaseProperty.java @@ -28,6 +28,12 @@ */ public interface BaseProperty { + String RESOURCE_REFERENCE_METADATA_KEY = "resourceReference"; + + enum ResourceType { + DATA_TABLE, KNOWLEDGE_BASE + } + /** * */ diff --git a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseDeleteAction.java b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseDeleteAction.java index d7f682d2870..67a37ed9f8d 100644 --- a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseDeleteAction.java +++ b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseDeleteAction.java @@ -28,6 +28,7 @@ import com.bytechef.component.definition.ActionDefinition; import com.bytechef.component.definition.Option; import com.bytechef.component.definition.Parameters; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.definition.MultipleConnectionsPerformFunction; import com.bytechef.platform.component.definition.ParametersFactory; import com.bytechef.platform.knowledgebase.domain.KnowledgeBase; @@ -56,6 +57,7 @@ public static ActionDefinition of( .properties( integer(KNOWLEDGE_BASE_ID) .label("Knowledge Base") + .resourceReference(ResourceType.KNOWLEDGE_BASE) .description("The knowledge base to delete documents from.") .options(getKnowledgeBaseOptions(knowledgeBaseService)) .required(true), diff --git a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseLoadAction.java b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseLoadAction.java index 1c84a653919..f76b886a61a 100644 --- a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseLoadAction.java +++ b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseLoadAction.java @@ -32,6 +32,7 @@ import com.bytechef.component.definition.ActionDefinition; import com.bytechef.component.definition.Option; import com.bytechef.component.definition.Parameters; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.ComponentConnection; import com.bytechef.platform.component.definition.ActionContextAware; import com.bytechef.platform.component.definition.MultipleConnectionsPerformFunction; @@ -86,6 +87,7 @@ public static ActionDefinition of( .properties( integer(KNOWLEDGE_BASE_ID) .label("Knowledge Base") + .resourceReference(ResourceType.KNOWLEDGE_BASE) .description("The knowledge base to load documents into.") .options(getKnowledgeBaseOptions(knowledgeBaseService)) .required(true), diff --git a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseSearchAction.java b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseSearchAction.java index 7bd3c126640..2400be6d221 100644 --- a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseSearchAction.java +++ b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseSearchAction.java @@ -36,6 +36,7 @@ import com.bytechef.component.definition.Option; import com.bytechef.component.definition.Parameters; import com.bytechef.component.definition.TypeReference; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.definition.MultipleConnectionsPerformFunction; import com.bytechef.platform.knowledgebase.domain.KnowledgeBase; import com.bytechef.platform.knowledgebase.service.KnowledgeBaseDocumentTagService; @@ -78,6 +79,7 @@ public static ActionDefinition of( .properties( integer(KNOWLEDGE_BASE_ID) .label("Knowledge Base") + .resourceReference(ResourceType.KNOWLEDGE_BASE) .description("The knowledge base to search.") .options(getKnowledgeBaseOptions(knowledgeBaseService)) .required(true), diff --git a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseUpdateAction.java b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseUpdateAction.java index 1a5a85630d2..a197b4897e0 100644 --- a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseUpdateAction.java +++ b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/action/KnowledgeBaseUpdateAction.java @@ -44,6 +44,7 @@ import com.bytechef.component.definition.ActionContext; import com.bytechef.component.definition.ActionDefinition; import com.bytechef.component.definition.Parameters; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.ComponentConnection; import com.bytechef.platform.component.definition.ActionContextAware; import com.bytechef.platform.component.definition.MultipleConnectionsPerformFunction; @@ -98,6 +99,7 @@ public static ActionDefinition of( .required(true), integer(KNOWLEDGE_BASE_ID) .label("Knowledge Base") + .resourceReference(ResourceType.KNOWLEDGE_BASE) .description("The knowledge base to update documents in.") .options(KnowledgeBaseOptionsUtils.knowledgeBaseActionOptions(knowledgeBaseService)) .required(true), diff --git a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/cluster/KnowledgeBaseSearchTool.java b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/cluster/KnowledgeBaseSearchTool.java index b64ef466141..084a3bcd85b 100644 --- a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/cluster/KnowledgeBaseSearchTool.java +++ b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/cluster/KnowledgeBaseSearchTool.java @@ -31,6 +31,7 @@ import com.bytechef.component.ai.vectorstore.knowledgebase.util.KnowledgeBaseOptionsUtils; import com.bytechef.component.definition.ClusterElementDefinition; import com.bytechef.component.definition.ComponentDsl; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.ComponentConnection; import com.bytechef.platform.component.definition.ParametersFactory; import com.bytechef.platform.component.definition.ai.agent.MultipleConnectionsToolFunction; @@ -59,6 +60,7 @@ public static ClusterElementDefinition of( Stream.of( integer(KNOWLEDGE_BASE_ID) .label("Knowledge Base") + .resourceReference(ResourceType.KNOWLEDGE_BASE) .description("The knowledge base to search.") .options(KnowledgeBaseOptionsUtils.knowledgeBaseOptions(knowledgeBaseService)) .required(true), diff --git a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/cluster/KnowledgeBaseUpdateTool.java b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/cluster/KnowledgeBaseUpdateTool.java index 83aecb8f512..191da5b4530 100644 --- a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/cluster/KnowledgeBaseUpdateTool.java +++ b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/cluster/KnowledgeBaseUpdateTool.java @@ -40,6 +40,7 @@ import com.bytechef.component.ai.vectorstore.knowledgebase.util.KnowledgeBaseOptionsUtils; import com.bytechef.component.definition.ClusterElementDefinition; import com.bytechef.component.definition.ComponentDsl; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.definition.ParametersFactory; import com.bytechef.platform.component.definition.ai.agent.MultipleConnectionsToolFunction; import com.bytechef.platform.knowledgebase.facade.KnowledgeBaseDocumentChunkFacade; @@ -83,6 +84,7 @@ public static ClusterElementDefinition of( .required(true), integer(KNOWLEDGE_BASE_ID) .label("Knowledge Base") + .resourceReference(ResourceType.KNOWLEDGE_BASE) .description("The knowledge base to update documents in.") .options(KnowledgeBaseOptionsUtils.knowledgeBaseOptions(knowledgeBaseService)) .required(true), diff --git a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/util/KnowledgeBaseVectorStore.java b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/util/KnowledgeBaseVectorStore.java index 8e98e4fa576..c12d36df4de 100644 --- a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/util/KnowledgeBaseVectorStore.java +++ b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/main/java/com/bytechef/component/ai/vectorstore/knowledgebase/util/KnowledgeBaseVectorStore.java @@ -37,6 +37,7 @@ import com.bytechef.component.definition.ComponentDsl; import com.bytechef.component.definition.Parameters; import com.bytechef.component.definition.TypeReference; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.file.storage.domain.FileEntry; import com.bytechef.platform.component.definition.ParametersFactory; import com.bytechef.platform.component.definition.ai.agent.VectorStoreFunction; @@ -87,6 +88,7 @@ public static ClusterElementDefinition of( .properties( ComponentDsl.integer(KNOWLEDGE_BASE_ID) .label("Knowledge Base") + .resourceReference(ResourceType.KNOWLEDGE_BASE) .description("The knowledge base to retrieve documents from.") .options(KnowledgeBaseOptionsUtils.knowledgeBaseOptions(knowledgeBaseService)) .required(true), diff --git a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/test/resources/definition/knowledgeBase_v1.json b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/test/resources/definition/knowledgeBase_v1.json index deb92817fc0..29390dcd419 100644 --- a/server/libs/modules/components/ai/vectorstore/knowledgebase/src/test/resources/definition/knowledgeBase_v1.json +++ b/server/libs/modules/components/ai/vectorstore/knowledgebase/src/test/resources/definition/knowledgeBase_v1.json @@ -1,9 +1,9 @@ { "actionClusterElementTypes": { - "load": [ "DOCUMENT_READER", "DOCUMENT_TRANSFORMER" ], - "search": [ ], + "delete": [ ], "update": [ "DOCUMENT_READER", "DOCUMENT_TRANSFORMER" ], - "delete": [ ] + "search": [ ], + "load": [ "DOCUMENT_READER", "DOCUMENT_TRANSFORMER" ] }, "actions": [ { "batch": null, @@ -29,7 +29,9 @@ "hidden": null, "label": "Knowledge Base", "maxValue": null, - "metadata": { }, + "metadata": { + "resourceReference": "KNOWLEDGE_BASE" + }, "minValue": null, "name": "knowledgeBaseId", "options": null, @@ -247,7 +249,9 @@ "hidden": null, "label": "Knowledge Base", "maxValue": null, - "metadata": { }, + "metadata": { + "resourceReference": "KNOWLEDGE_BASE" + }, "minValue": null, "name": "knowledgeBaseId", "options": null, @@ -449,7 +453,9 @@ "hidden": null, "label": "Knowledge Base", "maxValue": null, - "metadata": { }, + "metadata": { + "resourceReference": "KNOWLEDGE_BASE" + }, "minValue": null, "name": "knowledgeBaseId", "options": null, @@ -800,7 +806,9 @@ "hidden": null, "label": "Knowledge Base", "maxValue": null, - "metadata": { }, + "metadata": { + "resourceReference": "KNOWLEDGE_BASE" + }, "minValue": null, "name": "knowledgeBaseId", "options": null, @@ -1254,7 +1262,9 @@ "hidden": null, "label": "Knowledge Base", "maxValue": null, - "metadata": { }, + "metadata": { + "resourceReference": "KNOWLEDGE_BASE" + }, "minValue": null, "name": "knowledgeBaseId", "options": null, @@ -1426,7 +1436,9 @@ "hidden": null, "label": "Knowledge Base", "maxValue": null, - "metadata": { }, + "metadata": { + "resourceReference": "KNOWLEDGE_BASE" + }, "minValue": null, "name": "knowledgeBaseId", "options": null, @@ -1868,7 +1880,9 @@ "hidden": null, "label": "Knowledge Base", "maxValue": null, - "metadata": { }, + "metadata": { + "resourceReference": "KNOWLEDGE_BASE" + }, "minValue": null, "name": "knowledgeBaseId", "options": null, diff --git a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableClearTableAction.java b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableClearTableAction.java index 87164dc0604..b4d58ec17dd 100644 --- a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableClearTableAction.java +++ b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableClearTableAction.java @@ -27,6 +27,7 @@ import com.bytechef.component.datatable.util.DataTableUtils; import com.bytechef.component.definition.ActionContext; import com.bytechef.component.definition.Parameters; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.definition.ActionContextAware; import com.bytechef.platform.data.table.configuration.service.DataTableService; import com.bytechef.platform.data.table.execution.domain.DataTableRow; @@ -66,6 +67,7 @@ private ModifiableActionDefinition build() { .properties( string(TABLE) .label("Table") + .resourceReference(ResourceType.DATA_TABLE) .required(true) .options(DataTableUtils.getActionTableOptions(dataTableService))) .output( diff --git a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableCreateRecordsAction.java b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableCreateRecordsAction.java index 7dca820dd8f..b6df8d21ee5 100644 --- a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableCreateRecordsAction.java +++ b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableCreateRecordsAction.java @@ -31,6 +31,7 @@ import com.bytechef.component.definition.ActionContext; import com.bytechef.component.definition.Parameters; import com.bytechef.component.definition.Property; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.definition.ActionContextAware; import com.bytechef.platform.data.table.configuration.service.DataTableService; import com.bytechef.platform.data.table.execution.domain.DataTableRow; @@ -70,6 +71,7 @@ private ModifiableActionDefinition build() { .properties( string(TABLE) .label("Table") + .resourceReference(ResourceType.DATA_TABLE) .required(true) .options(DataTableUtils.getActionTableOptions(dataTableService)), dynamicProperties(RECORDS) diff --git a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableDeleteRecordsAction.java b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableDeleteRecordsAction.java index c1593fcbf1e..c1e8c090e93 100644 --- a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableDeleteRecordsAction.java +++ b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableDeleteRecordsAction.java @@ -29,6 +29,7 @@ import com.bytechef.component.datatable.util.DataTableUtils; import com.bytechef.component.definition.ActionContext; import com.bytechef.component.definition.Parameters; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.definition.ActionContextAware; import com.bytechef.platform.data.table.configuration.service.DataTableService; import com.bytechef.platform.data.table.execution.service.DataTableRowService; @@ -68,6 +69,7 @@ private ModifiableActionDefinition build() { .properties( string(TABLE) .label("Table") + .resourceReference(ResourceType.DATA_TABLE) .required(true) .options(DataTableUtils.getActionTableOptions(dataTableService)), array(IDS) diff --git a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableFindRecordsAction.java b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableFindRecordsAction.java index 9c4f74e4efd..eb4264a6ff5 100644 --- a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableFindRecordsAction.java +++ b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableFindRecordsAction.java @@ -31,6 +31,7 @@ import com.bytechef.component.definition.ActionContext; import com.bytechef.component.definition.Parameters; import com.bytechef.component.definition.Property; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.definition.ActionContextAware; import com.bytechef.platform.data.table.configuration.service.DataTableService; import com.bytechef.platform.data.table.execution.domain.DataTableRow; @@ -69,6 +70,7 @@ private ModifiableActionDefinition build() { .properties( string(TABLE) .label("Table") + .resourceReference(ResourceType.DATA_TABLE) .required(true) .options(DataTableUtils.getActionTableOptions(dataTableService)), integer(LIMIT) diff --git a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableGetRecordAction.java b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableGetRecordAction.java index d9c34a494d3..f7ddcb107e7 100644 --- a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableGetRecordAction.java +++ b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableGetRecordAction.java @@ -28,6 +28,7 @@ import com.bytechef.component.datatable.util.DataTableUtils; import com.bytechef.component.definition.ActionContext; import com.bytechef.component.definition.Parameters; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.definition.ActionContextAware; import com.bytechef.platform.data.table.configuration.service.DataTableService; import com.bytechef.platform.data.table.execution.domain.DataTableRow; @@ -66,6 +67,7 @@ private ModifiableActionDefinition build() { .properties( string(TABLE) .label("Table") + .resourceReference(ResourceType.DATA_TABLE) .required(true) .options(DataTableUtils.getActionTableOptions(dataTableService)), integer(ID) diff --git a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableUpdateRecordAction.java b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableUpdateRecordAction.java index 3f3bdcfe386..4da86dfad0e 100644 --- a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableUpdateRecordAction.java +++ b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/action/DataTableUpdateRecordAction.java @@ -30,6 +30,7 @@ import com.bytechef.component.datatable.util.DataTableUtils; import com.bytechef.component.definition.ActionContext; import com.bytechef.component.definition.Parameters; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.definition.ActionContextAware; import com.bytechef.platform.data.table.configuration.service.DataTableService; import com.bytechef.platform.data.table.execution.domain.DataTableRow; @@ -68,6 +69,7 @@ private ModifiableActionDefinition build() { .properties( string(TABLE) .label("Table") + .resourceReference(ResourceType.DATA_TABLE) .required(true) .options(DataTableUtils.getActionTableOptions(dataTableService)), integer(ID) diff --git a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/trigger/DataTableRecordCreatedTrigger.java b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/trigger/DataTableRecordCreatedTrigger.java index 246f08e96bd..ce0cedee2f6 100644 --- a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/trigger/DataTableRecordCreatedTrigger.java +++ b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/trigger/DataTableRecordCreatedTrigger.java @@ -32,6 +32,7 @@ import com.bytechef.component.definition.TriggerDefinition.WebhookMethod; import com.bytechef.component.definition.TypeReference; import com.bytechef.definition.BaseOutputDefinition.OutputResponse; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.definition.TriggerContextAware; import com.bytechef.platform.data.table.configuration.domain.DataTableWebhookType; import com.bytechef.platform.data.table.configuration.service.DataTableService; @@ -78,6 +79,7 @@ private ModifiableTriggerDefinition build() { .properties( string(TABLE) .label("Table") + .resourceReference(ResourceType.DATA_TABLE) .description("Select a Data Table.") .required(true) .options(DataTableUtils.getTriggerTableOptions(dataTableService))) diff --git a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/trigger/DataTableRecordDeletedTrigger.java b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/trigger/DataTableRecordDeletedTrigger.java index f06d0f929e6..dea2cac2fc8 100644 --- a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/trigger/DataTableRecordDeletedTrigger.java +++ b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/trigger/DataTableRecordDeletedTrigger.java @@ -29,6 +29,7 @@ import com.bytechef.component.definition.TriggerDefinition.WebhookBody; import com.bytechef.component.definition.TriggerDefinition.WebhookEnableOutput; import com.bytechef.component.definition.TypeReference; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.definition.TriggerContextAware; import com.bytechef.platform.data.table.configuration.domain.DataTableWebhookType; import com.bytechef.platform.data.table.configuration.service.DataTableService; @@ -75,6 +76,7 @@ private ModifiableTriggerDefinition build() { .properties( string(TABLE) .label("Table") + .resourceReference(ResourceType.DATA_TABLE) .description("Select a Data Table.") .required(true) .options( diff --git a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/trigger/DataTableRecordUpdatedTrigger.java b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/trigger/DataTableRecordUpdatedTrigger.java index 5859577abad..9c1e7846b98 100644 --- a/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/trigger/DataTableRecordUpdatedTrigger.java +++ b/server/libs/modules/components/data-table/src/main/java/com/bytechef/component/datatable/trigger/DataTableRecordUpdatedTrigger.java @@ -32,6 +32,7 @@ import com.bytechef.component.definition.TriggerDefinition.WebhookMethod; import com.bytechef.component.definition.TypeReference; import com.bytechef.definition.BaseOutputDefinition.OutputResponse; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.definition.TriggerContextAware; import com.bytechef.platform.data.table.configuration.domain.DataTableWebhookType; import com.bytechef.platform.data.table.configuration.service.DataTableService; @@ -78,6 +79,7 @@ private ModifiableTriggerDefinition build() { .properties( string(TABLE) .label("Table") + .resourceReference(ResourceType.DATA_TABLE) .description("Select a Data Table.") .required(true) .options(DataTableUtils.getTriggerTableOptions(dataTableService))) diff --git a/server/libs/platform/platform-api/src/main/java/com/bytechef/platform/domain/BaseProperty.java b/server/libs/platform/platform-api/src/main/java/com/bytechef/platform/domain/BaseProperty.java index 64bebe10d6b..5b64195672b 100644 --- a/server/libs/platform/platform-api/src/main/java/com/bytechef/platform/domain/BaseProperty.java +++ b/server/libs/platform/platform-api/src/main/java/com/bytechef/platform/domain/BaseProperty.java @@ -17,6 +17,7 @@ package com.bytechef.platform.domain; import com.bytechef.commons.util.OptionalUtils; +import com.bytechef.definition.BaseProperty.ResourceType; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.Map; @@ -29,6 +30,9 @@ public abstract class BaseProperty { protected boolean advancedOption; protected String description; protected String displayCondition; + + @Nullable + protected ResourceType resourceType; protected boolean expressionEnabled; // Defaults to true protected boolean hidden; protected Map metadata; @@ -45,6 +49,8 @@ public BaseProperty(com.bytechef.definition.BaseProperty property) { this.expressionEnabled = OptionalUtils.orElse(property.getExpressionEnabled(), true); this.hidden = OptionalUtils.orElse(property.getHidden(), false); this.metadata = property.getMetadata(); + + this.resourceType = getResourceType(property.getMetadata()); this.required = property.getRequired(); this.name = property.getName(); } @@ -63,12 +69,13 @@ public boolean equals(Object o) { && hidden == that.hidden && required == that.required && Objects.equals(displayCondition, that.displayCondition) && Objects.equals(name, that.name) - && Objects.equals(metadata, that.metadata); + && Objects.equals(metadata, that.metadata) && resourceType == that.resourceType; } @Override public int hashCode() { - return Objects.hash(advancedOption, displayCondition, expressionEnabled, hidden, metadata, required, name); + return Objects.hash( + advancedOption, displayCondition, expressionEnabled, hidden, metadata, required, name, resourceType); } public boolean getAdvancedOption() { @@ -85,6 +92,11 @@ public String getDisplayCondition() { return displayCondition; } + @Nullable + public ResourceType getResourceType() { + return resourceType; + } + public boolean getExpressionEnabled() { return expressionEnabled; } @@ -104,4 +116,27 @@ public String getName() { public Map getMetadata() { return metadata; } + + @Nullable + private static ResourceType getResourceType(@Nullable Map metadata) { + if (metadata == null) { + return null; + } + + Object value = metadata.get(com.bytechef.definition.BaseProperty.RESOURCE_REFERENCE_METADATA_KEY); + + if (value == null) { + return null; + } + + String resourceTypeName = value.toString(); + + for (ResourceType resourceType : ResourceType.values()) { + if (resourceTypeName.equals(resourceType.name())) { + return resourceType; + } + } + + return null; + } } diff --git a/server/libs/platform/platform-component/platform-component-api/src/test/java/com/bytechef/platform/component/domain/PropertyResourceTypeTest.java b/server/libs/platform/platform-component/platform-component-api/src/test/java/com/bytechef/platform/component/domain/PropertyResourceTypeTest.java new file mode 100644 index 00000000000..0ad93c113f7 --- /dev/null +++ b/server/libs/platform/platform-component/platform-component-api/src/test/java/com/bytechef/platform/component/domain/PropertyResourceTypeTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.component.domain; + +import static com.bytechef.component.definition.ComponentDsl.string; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.bytechef.component.definition.ComponentDsl.ModifiableStringProperty; +import com.bytechef.definition.BaseProperty; +import com.bytechef.definition.BaseProperty.ResourceType; +import org.junit.jupiter.api.Test; + +/** + * @author Ivica Cardic + */ +class PropertyResourceTypeTest { + + @Test + void resourceTypeIsReadFromMetadata() { + StringProperty stringProperty = new StringProperty(string("table").resourceReference(ResourceType.DATA_TABLE)); + + assertEquals(ResourceType.DATA_TABLE, stringProperty.getResourceType()); + } + + @Test + void resourceTypeIsNullWithoutMarker() { + StringProperty stringProperty = new StringProperty(string("table")); + + assertNull(stringProperty.getResourceType()); + } + + @Test + void unknownResourceTypeIsReadAsNoResourceType() { + ModifiableStringProperty modifiableStringProperty = string("table"); + + modifiableStringProperty.metadata(BaseProperty.RESOURCE_REFERENCE_METADATA_KEY, "SPREADSHEET"); + + StringProperty stringProperty = new StringProperty(modifiableStringProperty); + + assertNull(stringProperty.getResourceType()); + } +} diff --git a/server/libs/platform/platform-configuration/platform-configuration-service/src/main/java/com/bytechef/platform/configuration/facade/WorkflowNodeOutputFacadeImpl.java b/server/libs/platform/platform-configuration/platform-configuration-service/src/main/java/com/bytechef/platform/configuration/facade/WorkflowNodeOutputFacadeImpl.java index e63480d1eee..1a8076180b1 100644 --- a/server/libs/platform/platform-configuration/platform-configuration-service/src/main/java/com/bytechef/platform/configuration/facade/WorkflowNodeOutputFacadeImpl.java +++ b/server/libs/platform/platform-configuration/platform-configuration-service/src/main/java/com/bytechef/platform/configuration/facade/WorkflowNodeOutputFacadeImpl.java @@ -59,6 +59,8 @@ import java.util.Optional; import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.cache.annotation.Cacheable; import org.springframework.expression.EvaluationException; import org.springframework.stereotype.Service; @@ -72,6 +74,8 @@ @Transactional public class WorkflowNodeOutputFacadeImpl implements WorkflowNodeOutputFacade { + private static final Logger log = LoggerFactory.getLogger(WorkflowNodeOutputFacadeImpl.class); + private final ActionDefinitionFacade actionDefinitionFacade; private final ActionDefinitionService actionDefinitionService; private final ClusterElementDefinitionFacade clusterElementDefinitionFacade; @@ -278,7 +282,8 @@ private List doGetPreviousWorkflowNodeOutputs( break; } - workflowNodeOutputDTOs.add(getWorkflowNodeOutputDTO(workflowId, workflowTrigger, environmentId)); + workflowNodeOutputDTOs.add( + getWorkflowNodeOutputDTO(workflowId, workflowTrigger, environmentId, true)); } List workflowTasks = workflow.getTasks(lastWorkflowNodeName); @@ -310,14 +315,16 @@ private List doGetPreviousWorkflowNodeOutputs( if (containsWorkflowTask(childWorkflowTasks, lastWorkflowNodeName)) { workflowNodeOutputDTOs.add( - getWorkflowNodeOutputDTO(workflowId, workflowTask, false, environmentId, sampleOutputsCache)); + getWorkflowNodeOutputDTO(workflowId, workflowTask, false, environmentId, sampleOutputsCache, + true)); } else { workflowNodeOutputDTOs.add( - getWorkflowNodeOutputDTO(workflowId, workflowTask, true, environmentId, sampleOutputsCache)); + getWorkflowNodeOutputDTO(workflowId, workflowTask, true, environmentId, sampleOutputsCache, + true)); } } else { workflowNodeOutputDTOs.add( - getWorkflowNodeOutputDTO(workflowId, workflowTask, true, environmentId, sampleOutputsCache)); + getWorkflowNodeOutputDTO(workflowId, workflowTask, true, environmentId, sampleOutputsCache, true)); } } @@ -423,6 +430,14 @@ private WorkflowNodeOutputDTO getWorkflowNodeOutputDTO( String workflowId, WorkflowTask workflowTask, Boolean taskDispatcherOutput, long environmentId, Map> sampleOutputsCache) { + return getWorkflowNodeOutputDTO( + workflowId, workflowTask, taskDispatcherOutput, environmentId, sampleOutputsCache, false); + } + + private WorkflowNodeOutputDTO getWorkflowNodeOutputDTO( + String workflowId, WorkflowTask workflowTask, Boolean taskDispatcherOutput, long environmentId, + Map> sampleOutputsCache, boolean previousNode) { + WorkflowNodeType workflowNodeType = WorkflowNodeType.ofType(workflowTask.getType()); ActionDefinition actionDefinition = null; @@ -463,8 +478,17 @@ private WorkflowNodeOutputDTO getWorkflowNodeOutputDTO( outputResponse = checkOutputSchemaIsFileEntryProperty(actionDefinition.getOutputResponse()); if (outputResponse == null) { - outputResponse = getWorkflowTaskDynamicOutputResponse( - workflowId, workflowTask, environmentId, sampleOutputsCache); + try { + outputResponse = getWorkflowTaskDynamicOutputResponse( + workflowId, workflowTask, environmentId, sampleOutputsCache); + } catch (RuntimeException e) { + if (!previousNode) { + throw e; + } + + log.debug( + "Dynamic output of node {} was not resolved: {}", workflowTask.getName(), e.getMessage()); + } } } } else { @@ -480,6 +504,12 @@ private WorkflowNodeOutputDTO getWorkflowNodeOutputDTO( private WorkflowNodeOutputDTO getWorkflowNodeOutputDTO( String workflowId, WorkflowTrigger workflowTrigger, long environmentId) { + return getWorkflowNodeOutputDTO(workflowId, workflowTrigger, environmentId, false); + } + + private WorkflowNodeOutputDTO getWorkflowNodeOutputDTO( + String workflowId, WorkflowTrigger workflowTrigger, long environmentId, boolean previousNode) { + boolean testoutputResponse = false; WorkflowNodeType workflowNodeType = WorkflowNodeType.ofType(workflowTrigger.getType()); @@ -491,7 +521,8 @@ private WorkflowNodeOutputDTO getWorkflowNodeOutputDTO( OutputResponse outputResponse = workflowNodeTestOutputService .fetchWorkflowTestNodeOutput(workflowId, workflowTrigger.getName(), environmentId) .map(workflowNodeTestOutput -> workflowNodeTestOutput.getOutput(typeClass)) - .or(() -> getWorkflowTriggerDynamicOutputResponse(workflowId, workflowTrigger, environmentId)) + .or(() -> fetchWorkflowTriggerDynamicOutputResponse( + workflowId, workflowTrigger, environmentId, previousNode)) .orElse(null); if (outputResponse == null) { @@ -611,6 +642,23 @@ private WorkflowTaskDispatcherDynamicOutputResponse getWorkflowTaskDispatcherDyn return new WorkflowTaskDispatcherDynamicOutputResponse(outputResponse, variableOutputResponse); } + private Optional fetchWorkflowTriggerDynamicOutputResponse( + String workflowId, WorkflowTrigger workflowTrigger, long environmentId, boolean previousNode) { + + if (!previousNode) { + return getWorkflowTriggerDynamicOutputResponse(workflowId, workflowTrigger, environmentId); + } + + try { + return getWorkflowTriggerDynamicOutputResponse(workflowId, workflowTrigger, environmentId); + } catch (RuntimeException e) { + log.debug( + "Dynamic output of trigger {} was not resolved: {}", workflowTrigger.getName(), e.getMessage()); + + return Optional.empty(); + } + } + private Optional getWorkflowTriggerDynamicOutputResponse( String workflowId, WorkflowTrigger workflowTrigger, long environmentId) { diff --git a/server/libs/platform/platform-configuration/platform-configuration-service/src/test/java/com/bytechef/platform/configuration/facade/WorkflowNodeOutputFacadeTest.java b/server/libs/platform/platform-configuration/platform-configuration-service/src/test/java/com/bytechef/platform/configuration/facade/WorkflowNodeOutputFacadeTest.java index 1880e010d60..80fe46ec4db 100644 --- a/server/libs/platform/platform-configuration/platform-configuration-service/src/test/java/com/bytechef/platform/configuration/facade/WorkflowNodeOutputFacadeTest.java +++ b/server/libs/platform/platform-configuration/platform-configuration-service/src/test/java/com/bytechef/platform/configuration/facade/WorkflowNodeOutputFacadeTest.java @@ -36,6 +36,7 @@ import com.bytechef.atlas.configuration.service.WorkflowService; import com.bytechef.evaluator.Evaluator; import com.bytechef.platform.component.domain.ActionDefinition; +import com.bytechef.platform.component.domain.TriggerDefinition; import com.bytechef.platform.component.facade.ActionDefinitionFacade; import com.bytechef.platform.component.facade.ClusterElementDefinitionFacade; import com.bytechef.platform.component.facade.TriggerDefinitionFacade; @@ -224,6 +225,115 @@ void testGetPreviousWorkflowNodeOutputsIncludesLoopTaskDispatcherOutputForSiblin } } + @Test + void testNodeWhoseDynamicOutputFailsDoesNotBreakTheOutputsOfTheNodesAfterIt() { + WorkflowTask task1 = new WorkflowTask( + Map.of("name", "action1", "type", "component/v1/action1")); + WorkflowTask task2 = new WorkflowTask( + Map.of("name", "action2", "type", "component/v1/action2")); + WorkflowTask task3 = new WorkflowTask( + Map.of("name", "action3", "type", "component/v1/action3")); + + Workflow workflow = mock(Workflow.class); + + when(workflowService.getWorkflow(WORKFLOW_ID)).thenReturn(workflow); + when(workflow.getTasks(eq("action3"))).thenReturn(List.of(task1, task2, task3)); + when(workflow.getTasks(eq("action2"))).thenReturn(List.of(task1, task2)); + + ActionDefinition action1Definition = mock(ActionDefinition.class); + + when(workflowNodeTestOutputService.fetchWorkflowTestNodeOutput(eq(WORKFLOW_ID), eq("action1"), anyLong())) + .thenReturn(Optional.empty()); + when(actionDefinitionService.getActionDefinition("component", 1, "action1")) + .thenReturn(action1Definition); + when(action1Definition.getOutputResponse()) + .thenReturn(new OutputResponse(null, Map.of("field1", "value1"), null)); + + ActionDefinition action2Definition = mock(ActionDefinition.class); + + when(workflowNodeTestOutputService.fetchWorkflowTestNodeOutput(eq(WORKFLOW_ID), eq("action2"), anyLong())) + .thenReturn(Optional.empty()); + when(actionDefinitionService.getActionDefinition("component", 1, "action2")) + .thenReturn(action2Definition); + when(action2Definition.getOutputResponse()).thenReturn(null); + when(actionDefinitionService.isDynamicOutputDefined("component", 1, "action2")).thenReturn(true); + when(workflowTestConfigurationService.getWorkflowTestConfigurationInputs(WORKFLOW_ID, ENVIRONMENT_ID)) + .thenReturn(Map.of()); + when(workflowTestConfigurationService.getWorkflowTestConfigurationConnections( + WORKFLOW_ID, "action2", ENVIRONMENT_ID)) + .thenReturn(List.of()); + when(evaluator.evaluate(any(), any(), anyBoolean())) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(actionDefinitionFacade.executeOutput(eq("component"), eq(1), eq("action2"), any(), any())) + .thenThrow(new IllegalStateException("Table does not have primary key column 'id': dt_0_conversations")); + + try (MockedStatic workflowTriggerStatic = mockStatic(WorkflowTrigger.class)) { + workflowTriggerStatic.when(() -> WorkflowTrigger.of(workflow)) + .thenReturn(List.of()); + + List result = workflowNodeOutputFacade.getPreviousWorkflowNodeOutputs( + WORKFLOW_ID, "action3", ENVIRONMENT_ID); + + assertEquals(2, result.size()); + assertEquals("action1", result.get(0) + .workflowNodeName()); + assertNotNull(result.get(0) + .getSampleOutput()); + assertEquals("action2", result.get(1) + .workflowNodeName()); + + Map sampleOutputs = workflowNodeOutputFacade.getPreviousWorkflowNodeSampleOutputs( + WORKFLOW_ID, "action3", ENVIRONMENT_ID); + + assertEquals(Map.of("field1", "value1"), sampleOutputs.get("action1")); + } + } + + @Test + void testTriggerWhoseDynamicOutputFailsDoesNotBreakTheOutputsOfTheNodesAfterIt() { + WorkflowTrigger workflowTrigger = new WorkflowTrigger( + Map.of("name", "trigger_1", "type", "dataTable/v1/recordUpdated")); + WorkflowTask task1 = new WorkflowTask( + Map.of("name", "action1", "type", "component/v1/action1")); + + Workflow workflow = mock(Workflow.class); + + when(workflowService.getWorkflow(WORKFLOW_ID)).thenReturn(workflow); + when(workflow.getTasks(eq("action1"))).thenReturn(List.of(task1)); + + TriggerDefinition triggerDefinition = mock(TriggerDefinition.class); + + when(workflowNodeTestOutputService.fetchWorkflowTestNodeOutput(eq(WORKFLOW_ID), eq("trigger_1"), anyLong())) + .thenReturn(Optional.empty()); + when(triggerDefinitionService.getTriggerDefinition("dataTable", 1, "recordUpdated")) + .thenReturn(triggerDefinition); + when(triggerDefinition.getOutputResponse()) + .thenReturn(new OutputResponse(null, Map.of("row", "value"), null)); + when(workflowTestConfigurationService.getWorkflowTestConfigurationInputs(WORKFLOW_ID, ENVIRONMENT_ID)) + .thenReturn(Map.of()); + when(workflowTestConfigurationService.fetchWorkflowTestConfigurationConnectionId( + WORKFLOW_ID, "trigger_1", ENVIRONMENT_ID)) + .thenReturn(Optional.empty()); + when(evaluator.evaluate(any(), any(), anyBoolean())) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(triggerDefinitionFacade.executeOutput( + eq("dataTable"), eq(1), eq("recordUpdated"), any(), any())) + .thenThrow( + new IllegalStateException("Table does not have primary key column 'id': dt_0_conversations")); + + try (MockedStatic workflowTriggerStatic = mockStatic(WorkflowTrigger.class)) { + workflowTriggerStatic.when(() -> WorkflowTrigger.of(workflow)) + .thenReturn(List.of(workflowTrigger)); + + List result = workflowNodeOutputFacade.getPreviousWorkflowNodeOutputs( + WORKFLOW_ID, "action1", ENVIRONMENT_ID); + + assertEquals(1, result.size()); + assertEquals("trigger_1", result.get(0) + .workflowNodeName()); + } + } + @Test void testSampleOutputsCachePreventsDuplicateComputation() { WorkflowTask task1 = new WorkflowTask( diff --git a/server/libs/platform/platform-data-table/platform-data-table-service/build.gradle.kts b/server/libs/platform/platform-data-table/platform-data-table-service/build.gradle.kts index 06df1a19d4d..494a6478717 100644 --- a/server/libs/platform/platform-data-table/platform-data-table-service/build.gradle.kts +++ b/server/libs/platform/platform-data-table/platform-data-table-service/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { implementation(project(":server:libs:core:commons:commons-util")) implementation(project(":server:libs:platform:platform-configuration:platform-configuration-api")) implementation(project(":server:libs:platform:platform-tag:platform-tag-api")) + implementation(project(":server:libs:platform:platform-workflow:platform-workflow-validator:platform-workflow-validator-api")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.mockito:mockito-core") diff --git a/server/libs/platform/platform-data-table/platform-data-table-service/src/main/java/com/bytechef/platform/data/table/configuration/service/DataTableReferenceResolver.java b/server/libs/platform/platform-data-table/platform-data-table-service/src/main/java/com/bytechef/platform/data/table/configuration/service/DataTableReferenceResolver.java new file mode 100644 index 00000000000..dad9d59d960 --- /dev/null +++ b/server/libs/platform/platform-data-table/platform-data-table-service/src/main/java/com/bytechef/platform/data/table/configuration/service/DataTableReferenceResolver.java @@ -0,0 +1,65 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.data.table.configuration.service; + +import com.bytechef.definition.BaseProperty.ResourceType; +import com.bytechef.platform.data.table.execution.service.DataTableRowService; +import com.bytechef.platform.workflow.validator.ResourceReferenceResolver; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.jspecify.annotations.Nullable; +import org.springframework.stereotype.Component; + +/** + * @author Ivica Cardic + */ +@Component +public class DataTableReferenceResolver implements ResourceReferenceResolver { + + private final DataTableRowService dataTableRowService; + private final DataTableService dataTableService; + + @SuppressFBWarnings("EI") + public DataTableReferenceResolver(DataTableRowService dataTableRowService, DataTableService dataTableService) { + this.dataTableRowService = dataTableRowService; + this.dataTableService = dataTableService; + } + + @Override + public ResourceType getResourceType() { + return ResourceType.DATA_TABLE; + } + + @Override + @Nullable + public String findProblem(String reference, long environmentId) { + boolean exists = dataTableService.listTables(environmentId) + .stream() + .anyMatch(dataTableInfo -> reference.equalsIgnoreCase(dataTableInfo.baseName())); + + if (!exists) { + return "Data table '" + reference + "' does not exist in this environment"; + } + + try { + dataTableRowService.listRows(reference, 1, 0, environmentId); + } catch (IllegalStateException e) { + return e.getMessage(); + } + + return null; + } +} diff --git a/server/libs/platform/platform-data-table/platform-data-table-service/src/test/java/com/bytechef/platform/data/table/configuration/service/DataTableReferenceResolverTest.java b/server/libs/platform/platform-data-table/platform-data-table-service/src/test/java/com/bytechef/platform/data/table/configuration/service/DataTableReferenceResolverTest.java new file mode 100644 index 00000000000..e75c433fb63 --- /dev/null +++ b/server/libs/platform/platform-data-table/platform-data-table-service/src/test/java/com/bytechef/platform/data/table/configuration/service/DataTableReferenceResolverTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.data.table.configuration.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.bytechef.definition.BaseProperty.ResourceType; +import com.bytechef.platform.data.table.configuration.domain.DataTableInfo; +import com.bytechef.platform.data.table.execution.service.DataTableRowService; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * @author Ivica Cardic + */ +class DataTableReferenceResolverTest { + + private final DataTableRowService dataTableRowService = mock(DataTableRowService.class); + private final DataTableService dataTableService = mock(DataTableService.class); + private final DataTableReferenceResolver resolver = + new DataTableReferenceResolver(dataTableRowService, dataTableService); + + @Test + void resourceTypeIsDataTable() { + assertEquals(ResourceType.DATA_TABLE, resolver.getResourceType()); + } + + @Test + void existingTableResolves() { + when(dataTableService.listTables(0L)).thenReturn(List.of(table("conversations"))); + + assertNull(resolver.findProblem("Conversations", 0L)); + } + + @Test + void missingTableReportsEnvironment() { + when(dataTableService.listTables(1L)).thenReturn(List.of(table("orders"))); + + assertEquals( + "Data table 'conversations' does not exist in this environment", resolver.findProblem("conversations", 1L)); + } + + @Test + void tableWithoutPrimaryKeyReportsRowServiceReason() { + when(dataTableService.listTables(0L)).thenReturn(List.of(table("conversations"))); + when(dataTableRowService.listRows(eq("conversations"), anyInt(), anyInt(), anyLong())) + .thenThrow(new IllegalStateException("Table does not have primary key column 'id': dt_0_conversations")); + + assertEquals( + "Table does not have primary key column 'id': dt_0_conversations", + resolver.findProblem("conversations", 0L)); + } + + private static DataTableInfo table(String baseName) { + return new DataTableInfo(1L, baseName, null, List.of(), Instant.now()); + } +} diff --git a/server/libs/platform/platform-knowledge-base/platform-knowledge-base-service/build.gradle.kts b/server/libs/platform/platform-knowledge-base/platform-knowledge-base-service/build.gradle.kts index dec7e30e13f..004f1cd6781 100644 --- a/server/libs/platform/platform-knowledge-base/platform-knowledge-base-service/build.gradle.kts +++ b/server/libs/platform/platform-knowledge-base/platform-knowledge-base-service/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(project(":server:libs:core:tenant:tenant-api")) implementation(project(":server:libs:platform:platform-configuration:platform-configuration-api")) implementation(project(":server:libs:platform:platform-tag:platform-tag-api")) + implementation(project(":server:libs:platform:platform-workflow:platform-workflow-validator:platform-workflow-validator-api")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.mockito:mockito-core") diff --git a/server/libs/platform/platform-knowledge-base/platform-knowledge-base-service/src/main/java/com/bytechef/platform/knowledgebase/service/KnowledgeBaseReferenceResolver.java b/server/libs/platform/platform-knowledge-base/platform-knowledge-base-service/src/main/java/com/bytechef/platform/knowledgebase/service/KnowledgeBaseReferenceResolver.java new file mode 100644 index 00000000000..b2ef8b83e65 --- /dev/null +++ b/server/libs/platform/platform-knowledge-base/platform-knowledge-base-service/src/main/java/com/bytechef/platform/knowledgebase/service/KnowledgeBaseReferenceResolver.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.knowledgebase.service; + +import com.bytechef.definition.BaseProperty.ResourceType; +import com.bytechef.platform.workflow.validator.ResourceReferenceResolver; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.Objects; +import org.jspecify.annotations.Nullable; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * @author Ivica Cardic + */ +@Component +@ConditionalOnProperty(prefix = "bytechef.ai.knowledge-base", name = "enabled", havingValue = "true") +public class KnowledgeBaseReferenceResolver implements ResourceReferenceResolver { + + private final KnowledgeBaseService knowledgeBaseService; + + @SuppressFBWarnings("EI") + public KnowledgeBaseReferenceResolver(KnowledgeBaseService knowledgeBaseService) { + this.knowledgeBaseService = knowledgeBaseService; + } + + @Override + public ResourceType getResourceType() { + return ResourceType.KNOWLEDGE_BASE; + } + + @Override + @Nullable + public String findProblem(String reference, long environmentId) { + long knowledgeBaseId; + + try { + knowledgeBaseId = Long.parseLong(reference); + } catch (NumberFormatException e) { + return "Knowledge base reference '" + reference + "' is not a valid id"; + } + + boolean exists = knowledgeBaseService.getKnowledgeBases() + .stream() + .anyMatch(knowledgeBase -> Objects.equals(knowledgeBase.getId(), knowledgeBaseId) && + knowledgeBase.getEnvironmentId() == environmentId); + + if (exists) { + return null; + } + + return "Knowledge base with id " + knowledgeBaseId + " does not exist in this environment"; + } +} diff --git a/server/libs/platform/platform-knowledge-base/platform-knowledge-base-service/src/test/java/com/bytechef/platform/knowledgebase/service/KnowledgeBaseReferenceResolverRegistrationTest.java b/server/libs/platform/platform-knowledge-base/platform-knowledge-base-service/src/test/java/com/bytechef/platform/knowledgebase/service/KnowledgeBaseReferenceResolverRegistrationTest.java new file mode 100644 index 00000000000..586769408bb --- /dev/null +++ b/server/libs/platform/platform-knowledge-base/platform-knowledge-base-service/src/test/java/com/bytechef/platform/knowledgebase/service/KnowledgeBaseReferenceResolverRegistrationTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.knowledgebase.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * @author Ivica Cardic + */ +class KnowledgeBaseReferenceResolverRegistrationTest { + + private final ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner() + .withBean(KnowledgeBaseService.class, () -> mock(KnowledgeBaseService.class)) + .withUserConfiguration(KnowledgeBaseReferenceResolver.class); + + @Test + void resolverIsNotRegisteredWhenKnowledgeBaseIsDisabled() { + applicationContextRunner.run(context -> assertThat(context).hasNotFailed() + .doesNotHaveBean(KnowledgeBaseReferenceResolver.class)); + } + + @Test + void resolverIsRegisteredWhenKnowledgeBaseIsEnabled() { + applicationContextRunner.withPropertyValues("bytechef.ai.knowledge-base.enabled=true") + .run(context -> assertThat(context).hasNotFailed() + .hasSingleBean(KnowledgeBaseReferenceResolver.class)); + } +} diff --git a/server/libs/platform/platform-knowledge-base/platform-knowledge-base-service/src/test/java/com/bytechef/platform/knowledgebase/service/KnowledgeBaseReferenceResolverTest.java b/server/libs/platform/platform-knowledge-base/platform-knowledge-base-service/src/test/java/com/bytechef/platform/knowledgebase/service/KnowledgeBaseReferenceResolverTest.java new file mode 100644 index 00000000000..cb9c7cbca4f --- /dev/null +++ b/server/libs/platform/platform-knowledge-base/platform-knowledge-base-service/src/test/java/com/bytechef/platform/knowledgebase/service/KnowledgeBaseReferenceResolverTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.knowledgebase.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.bytechef.definition.BaseProperty.ResourceType; +import com.bytechef.platform.knowledgebase.domain.KnowledgeBase; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * @author Ivica Cardic + */ +class KnowledgeBaseReferenceResolverTest { + + private final KnowledgeBaseService knowledgeBaseService = mock(KnowledgeBaseService.class); + private final KnowledgeBaseReferenceResolver resolver = new KnowledgeBaseReferenceResolver(knowledgeBaseService); + + @Test + void resourceTypeIsKnowledgeBase() { + assertEquals(ResourceType.KNOWLEDGE_BASE, resolver.getResourceType()); + } + + @Test + void existingKnowledgeBaseInEnvironmentResolves() { + KnowledgeBase knowledgeBase = knowledgeBase(7L, 1L); + + when(knowledgeBaseService.getKnowledgeBases()).thenReturn(List.of(knowledgeBase)); + + assertNull(resolver.findProblem("7", 1L)); + } + + @Test + void knowledgeBaseInOtherEnvironmentIsMissing() { + KnowledgeBase knowledgeBase = knowledgeBase(7L, 0L); + + when(knowledgeBaseService.getKnowledgeBases()).thenReturn(List.of(knowledgeBase)); + + assertEquals("Knowledge base with id 7 does not exist in this environment", resolver.findProblem("7", 1L)); + } + + @Test + void nonNumericReferenceIsReported() { + assertEquals("Knowledge base reference 'abc' is not a valid id", resolver.findProblem("abc", 0L)); + } + + private static KnowledgeBase knowledgeBase(long id, long environmentId) { + KnowledgeBase knowledgeBase = mock(KnowledgeBase.class); + + when(knowledgeBase.getId()).thenReturn(id); + when(knowledgeBase.getEnvironmentId()).thenReturn(environmentId); + + return knowledgeBase; + } +} diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-api/build.gradle.kts b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-api/build.gradle.kts index cbc83500816..a9dd76324ed 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-api/build.gradle.kts +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-api/build.gradle.kts @@ -1,4 +1,5 @@ dependencies { + api(project(":sdks:backend:java:definition-api")) api(project(":server:libs:core:exception:exception-api")) api("com.github.spotbugs:spotbugs-annotations") diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-api/src/main/java/com/bytechef/platform/workflow/validator/ResourceReferenceResolver.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-api/src/main/java/com/bytechef/platform/workflow/validator/ResourceReferenceResolver.java new file mode 100644 index 00000000000..977cb14c76f --- /dev/null +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-api/src/main/java/com/bytechef/platform/workflow/validator/ResourceReferenceResolver.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.workflow.validator; + +import com.bytechef.definition.BaseProperty.ResourceType; +import org.jspecify.annotations.Nullable; + +/** + * @author Ivica Cardic + */ +public interface ResourceReferenceResolver { + + ResourceType getResourceType(); + + @Nullable + String findProblem(String reference, long environmentId); +} diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-api/src/main/java/com/bytechef/platform/workflow/validator/WorkflowValidatorFacade.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-api/src/main/java/com/bytechef/platform/workflow/validator/WorkflowValidatorFacade.java index f91b2245db3..adb466c38df 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-api/src/main/java/com/bytechef/platform/workflow/validator/WorkflowValidatorFacade.java +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-api/src/main/java/com/bytechef/platform/workflow/validator/WorkflowValidatorFacade.java @@ -20,6 +20,7 @@ import com.bytechef.platform.workflow.validator.exception.WorkflowValidatorErrorType; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.List; +import org.jspecify.annotations.Nullable; /** * Facade for workflow validation operations. @@ -44,6 +45,10 @@ public interface WorkflowValidatorFacade { */ WorkflowValidationResult validateWorkflowById(String workflowId); + WorkflowValidationResult validateWorkflow(String workflow, long environmentId); + + WorkflowValidationResult validateWorkflowById(String workflowId, long environmentId); + /** * Returns the node names (the trigger plus all tasks, including tasks nested inside condition, loop, branch, * parallel, each, fork-join and on-error dispatchers) that occur more than once. Node names are global ids, so a @@ -114,15 +119,34 @@ default void validateNoDuplicateNodeNames(String workflow) { } } - /** - * Holds the result of a workflow validation, containing lists of error messages and warning messages. - */ + enum WorkflowIssueKind { + BROKEN_REFERENCE, DUPLICATE_NODE_NAME, MISSING_CLUSTER_ELEMENT, MISSING_REQUIRED, MISSING_RESOURCE, OTHER, + TASK_ORDER, TYPE_MISMATCH + } + + enum WorkflowIssueSeverity { + ERROR, WARNING + } + + record NodeValidationIssue( + String nodeName, @Nullable String propertyPath, WorkflowIssueKind kind, WorkflowIssueSeverity severity, + String message) { + } + @SuppressFBWarnings("EI") - record WorkflowValidationResult(List errors, List warnings) { + record WorkflowValidationResult( + List errors, List warnings, List nodeIssues) { public WorkflowValidationResult(List errors, List warnings) { + this(errors, warnings, List.of()); + } + + public WorkflowValidationResult( + List errors, List warnings, List nodeIssues) { + this.errors = List.copyOf(errors); this.warnings = List.copyOf(warnings); + this.nodeIssues = List.copyOf(nodeIssues); } } } diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-api/src/main/java/com/bytechef/platform/workflow/validator/model/PropertyInfo.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-api/src/main/java/com/bytechef/platform/workflow/validator/model/PropertyInfo.java index 9a3094620b7..a323fe9ecdf 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-api/src/main/java/com/bytechef/platform/workflow/validator/model/PropertyInfo.java +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-api/src/main/java/com/bytechef/platform/workflow/validator/model/PropertyInfo.java @@ -33,12 +33,20 @@ public record PropertyInfo( @JsonProperty("expressionEnabled") @JsonPropertyDescription("Whether expressions are enabled for this property") boolean expressionEnabled, @JsonProperty("displayCondition") @JsonPropertyDescription("The display condition for the property") String displayCondition, @JsonProperty("options") @JsonPropertyDescription("Available options for the property") List options, - @JsonProperty("nestedProperties") @JsonPropertyDescription("Nested properties for object/array/file_entry types") List nestedProperties) { + @JsonProperty("nestedProperties") @JsonPropertyDescription("Nested properties for object/array/file_entry types") List nestedProperties, + @JsonProperty("resourceType") @JsonPropertyDescription("The internal resource type the property value references, if any") String resourceType) { public PropertyInfo( String name, String type, String description, boolean required, boolean expressionEnabled, String displayCondition, List nestedProperties) { - this(name, type, description, required, expressionEnabled, displayCondition, null, nestedProperties); + this(name, type, description, required, expressionEnabled, displayCondition, null, nestedProperties, null); + } + + public PropertyInfo( + String name, String type, String description, boolean required, boolean expressionEnabled, + String displayCondition, List options, List nestedProperties) { + + this(name, type, description, required, expressionEnabled, displayCondition, options, nestedProperties, null); } } diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-graphql/src/main/java/com/bytechef/platform/workflow/validator/web/graphql/WorkflowValidatorGraphQlController.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-graphql/src/main/java/com/bytechef/platform/workflow/validator/web/graphql/WorkflowValidatorGraphQlController.java index ae0b25019de..f44493263c2 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-graphql/src/main/java/com/bytechef/platform/workflow/validator/web/graphql/WorkflowValidatorGraphQlController.java +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-graphql/src/main/java/com/bytechef/platform/workflow/validator/web/graphql/WorkflowValidatorGraphQlController.java @@ -19,6 +19,7 @@ import com.bytechef.atlas.coordinator.annotation.ConditionalOnCoordinator; import com.bytechef.platform.workflow.validator.WorkflowValidatorFacade; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.jspecify.annotations.Nullable; import org.springframework.graphql.data.method.annotation.Argument; import org.springframework.graphql.data.method.annotation.QueryMapping; import org.springframework.stereotype.Controller; @@ -38,12 +39,24 @@ public WorkflowValidatorGraphQlController(WorkflowValidatorFacade workflowValida } @QueryMapping - public WorkflowValidatorFacade.WorkflowValidationResult validateWorkflow(@Argument String workflow) { - return workflowValidatorFacade.validateWorkflow(workflow); + public WorkflowValidatorFacade.WorkflowValidationResult validateWorkflow( + @Argument String workflow, @Argument @Nullable Long environmentId) { + + if (environmentId == null) { + return workflowValidatorFacade.validateWorkflow(workflow); + } + + return workflowValidatorFacade.validateWorkflow(workflow, environmentId); } @QueryMapping - public WorkflowValidatorFacade.WorkflowValidationResult validateWorkflowById(@Argument String workflowId) { - return workflowValidatorFacade.validateWorkflowById(workflowId); + public WorkflowValidatorFacade.WorkflowValidationResult validateWorkflowById( + @Argument String workflowId, @Argument @Nullable Long environmentId) { + + if (environmentId == null) { + return workflowValidatorFacade.validateWorkflowById(workflowId); + } + + return workflowValidatorFacade.validateWorkflowById(workflowId, environmentId); } } diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-graphql/src/main/resources/graphql/workflow-validator.graphqls b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-graphql/src/main/resources/graphql/workflow-validator.graphqls index 7ed1c078acc..0987679d832 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-graphql/src/main/resources/graphql/workflow-validator.graphqls +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-graphql/src/main/resources/graphql/workflow-validator.graphqls @@ -1,9 +1,34 @@ extend type Query { - validateWorkflow(workflow: String!): WorkflowValidationResult! - validateWorkflowById(workflowId: String!): WorkflowValidationResult! + validateWorkflow(workflow: String!, environmentId: Long): WorkflowValidationResult! + validateWorkflowById(workflowId: String!, environmentId: Long): WorkflowValidationResult! } type WorkflowValidationResult { errors: [String!]! warnings: [String!]! + nodeIssues: [NodeValidationIssue!]! +} + +type NodeValidationIssue { + nodeName: String! + propertyPath: String + kind: WorkflowIssueKind! + severity: WorkflowIssueSeverity! + message: String! +} + +enum WorkflowIssueKind { + BROKEN_REFERENCE + DUPLICATE_NODE_NAME + MISSING_CLUSTER_ELEMENT + MISSING_REQUIRED + MISSING_RESOURCE + OTHER + TASK_ORDER + TYPE_MISMATCH +} + +enum WorkflowIssueSeverity { + ERROR + WARNING } diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-graphql/src/test/java/com/bytechef/platform/workflow/validator/web/graphql/WorkflowValidatorGraphQlControllerTest.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-graphql/src/test/java/com/bytechef/platform/workflow/validator/web/graphql/WorkflowValidatorGraphQlControllerTest.java new file mode 100644 index 00000000000..fd3fc2122cc --- /dev/null +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-graphql/src/test/java/com/bytechef/platform/workflow/validator/web/graphql/WorkflowValidatorGraphQlControllerTest.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.workflow.validator.web.graphql; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import com.bytechef.platform.workflow.validator.WorkflowValidatorFacade; +import org.junit.jupiter.api.Test; + +/** + * @author Ivica Cardic + */ +class WorkflowValidatorGraphQlControllerTest { + + private final WorkflowValidatorFacade workflowValidatorFacade = mock(WorkflowValidatorFacade.class); + private final WorkflowValidatorGraphQlController controller = + new WorkflowValidatorGraphQlController(workflowValidatorFacade); + + @Test + void passesEnvironmentThroughWhenGiven() { + controller.validateWorkflow("{}", 2L); + + verify(workflowValidatorFacade).validateWorkflow("{}", 2L); + } + + @Test + void usesFacadeDefaultWhenEnvironmentIsAbsent() { + controller.validateWorkflow("{}", null); + + verify(workflowValidatorFacade).validateWorkflow("{}"); + } + + @Test + void validateByIdPassesEnvironmentThrough() { + controller.validateWorkflowById("wf-1", 1L); + + verify(workflowValidatorFacade).validateWorkflowById("wf-1", 1L); + } +} diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/build.gradle.kts b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/build.gradle.kts index 9a9cd91a772..a9b017c4ace 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/build.gradle.kts +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation(project(":server:libs:core:evaluator:evaluator-impl")) implementation(project(":server:libs:atlas:atlas-configuration:atlas-configuration-api")) implementation(project(":server:libs:platform:platform-component:platform-component-api")) + implementation(project(":server:libs:platform:platform-configuration:platform-configuration-api")) implementation(project(":server:libs:platform:platform-workflow:platform-workflow-task-dispatcher:platform-workflow-task-dispatcher-api")) testImplementation(project(":server:libs:test:test-support")) diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/DataPillValidator.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/DataPillValidator.java index 783be22f4ad..ff304abca29 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/DataPillValidator.java +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/DataPillValidator.java @@ -32,6 +32,7 @@ class DataPillValidator { private static final Pattern DATA_PILL_PATTERN = Pattern.compile("\\$\\{([^}]+)}"); + private static final Pattern LOOP_ITEM_SEGMENT_PATTERN = Pattern.compile("\\.item(?!\\w)"); private DataPillValidator() { } @@ -328,7 +329,7 @@ private static void validateLoopItemTypes( continue; } - String indexedExpression = dataPillExpression.replace(".item", ".item[" + i + "]"); + String indexedExpression = indexLoopItem(dataPillExpression, i); String errorMessage = "Property '" + indexedExpression + "' in output of 'loop/v1' is of type " + actualType.toLowerCase() + ", not " + expectedType.toLowerCase(); @@ -352,6 +353,12 @@ private static void validateLoopItemTypes( } } + private static String indexLoopItem(String dataPillExpression, int index) { + Matcher matcher = LOOP_ITEM_SEGMENT_PATTERN.matcher(dataPillExpression); + + return matcher.replaceFirst(".item[" + index + "]"); + } + private static void validateLoopItemTypesFromDataPill( String dataPillExpression, String itemsDataPill, String expectedType, Map allTasksMap, StringBuilder errors, Map taskOutput) { @@ -411,16 +418,11 @@ private static void validateLoopItemTypesFromDataPill( String actualType = mapTypeToString(targetProperty.type()); if (!isTypeCompatible(expectedType, actualType)) { - // Generate errors for each array element (simulating 3 elements based on test - // expectations) - for (int i = 0; i < 3; i++) { - String errorMessage = String.format( - "Property 'loop1.item[%d].%s' in output of 'loop/v1' is of type %s, " + - "not %s", - i, propertyName, actualType, expectedType.toLowerCase()); - - StringUtils.appendWithNewline(errorMessage, errors); - } + String errorMessage = String.format( + "Property '%s' in output of 'loop/v1' is of type %s, not %s", + indexLoopItem(dataPillExpression, 0), actualType, expectedType.toLowerCase()); + + StringUtils.appendWithNewline(errorMessage, errors); } } } @@ -440,8 +442,8 @@ private static void validateLoopItemTypesFromDataPill( if (!isTypeCompatible(expectedType, actualType)) { String errorMessage = String.format( - "Property 'loop1.item[0]' in output of 'loop/v1' is of type %s, not %s", - actualType, expectedType.toLowerCase()); + "Property '%s' in output of 'loop/v1' is of type %s, not %s", + indexLoopItem(dataPillExpression, 0), actualType, expectedType.toLowerCase()); StringUtils.appendWithNewline(errorMessage, errors); } @@ -451,8 +453,8 @@ private static void validateLoopItemTypesFromDataPill( if (!isTypeCompatible(expectedType, actualType)) { String errorMessage = String.format( - "Property 'loop1.item[0]' in output of 'loop/v1' is of type %s, not %s", - actualType, expectedType.toLowerCase()); + "Property '%s' in output of 'loop/v1' is of type %s, not %s", + indexLoopItem(dataPillExpression, 0), actualType, expectedType.toLowerCase()); StringUtils.appendWithNewline(errorMessage, errors); } diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/NodeValidationIssueParser.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/NodeValidationIssueParser.java new file mode 100644 index 00000000000..a0fc1cb946b --- /dev/null +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/NodeValidationIssueParser.java @@ -0,0 +1,138 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.workflow.validator; + +import com.bytechef.platform.workflow.validator.WorkflowValidatorFacade.NodeValidationIssue; +import com.bytechef.platform.workflow.validator.WorkflowValidatorFacade.WorkflowIssueKind; +import com.bytechef.platform.workflow.validator.WorkflowValidatorFacade.WorkflowIssueSeverity; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** + * @author Ivica Cardic + */ +class NodeValidationIssueParser { + + private static final Pattern TASK_PREFIX_PATTERN = Pattern.compile("^\\[([^\\]]+)] (.*)$", Pattern.DOTALL); + private static final Pattern DUPLICATE_NODE_NAME_PATTERN = Pattern.compile( + "^Node names must be unique\\. Duplicate node name: (.+)$"); + private static final List MESSAGE_TEMPLATES = List.of( + new MessageTemplate(Pattern.compile("^Missing required property: (.+)$"), WorkflowIssueKind.MISSING_REQUIRED), + new MessageTemplate(Pattern.compile("^Missing required field: (.+)$"), WorkflowIssueKind.MISSING_REQUIRED), + new MessageTemplate( + Pattern.compile("^Resource referenced by property '([^']+)' is not available: .*$", Pattern.DOTALL), + WorkflowIssueKind.MISSING_RESOURCE), + new MessageTemplate( + Pattern.compile("^Could not verify the resource referenced by property '([^']+)': .*$", Pattern.DOTALL), + WorkflowIssueKind.MISSING_RESOURCE), + new MessageTemplate( + Pattern.compile("^Wrong task order: You can't reference '([^']+)' .*$"), WorkflowIssueKind.TASK_ORDER), + new MessageTemplate( + Pattern.compile("^Property '([^']+)' does not exist in the output of .*$"), + WorkflowIssueKind.BROKEN_REFERENCE), + new MessageTemplate( + Pattern.compile("^Property '([^']+)' might not exist in the output of .*$"), + WorkflowIssueKind.BROKEN_REFERENCE), + new MessageTemplate( + Pattern.compile("^Property '([^']+)' has incorrect type\\..*$"), WorkflowIssueKind.TYPE_MISMATCH), + new MessageTemplate( + Pattern.compile("^Property '([^']+)' in output of '[^']+' is of type .*$"), + WorkflowIssueKind.TYPE_MISMATCH), + new MessageTemplate( + Pattern.compile("^Value .* has incorrect type in property '([^']+)'\\..*$"), + WorkflowIssueKind.TYPE_MISMATCH), + new MessageTemplate( + Pattern.compile("^Cluster element '([^']+)' .*$"), WorkflowIssueKind.MISSING_CLUSTER_ELEMENT), + new MessageTemplate( + Pattern.compile("^Property '([^']+)' is not defined in task definition$"), WorkflowIssueKind.OTHER), + new MessageTemplate(Pattern.compile("^Field '([^']+)' must .*$"), WorkflowIssueKind.TYPE_MISMATCH), + new MessageTemplate( + Pattern.compile("^Missing recommended field: (.+)$"), WorkflowIssueKind.MISSING_REQUIRED), + new MessageTemplate( + Pattern.compile("^Task '([^']+)' doesn't exist\\.$"), WorkflowIssueKind.BROKEN_REFERENCE), + new MessageTemplate( + Pattern.compile("^Input property '([^']+)' is of type .*$"), WorkflowIssueKind.TYPE_MISMATCH), + new MessageTemplate( + Pattern.compile("^Property '([^']+)' is in incorrect .*$"), WorkflowIssueKind.TYPE_MISMATCH), + new MessageTemplate( + Pattern.compile("^Property '([^']+)' does not match any of the expected union types: .*$"), + WorkflowIssueKind.TYPE_MISMATCH), + new MessageTemplate( + Pattern.compile("^Invalid logic for display condition: (.+)$"), WorkflowIssueKind.OTHER)); + + private NodeValidationIssueParser() { + } + + static List parse(List errors, List warnings) { + List nodeValidationIssues = new ArrayList<>(); + + for (String error : errors) { + NodeValidationIssue nodeValidationIssue = toIssue(error, WorkflowIssueSeverity.ERROR); + + if (nodeValidationIssue != null) { + nodeValidationIssues.add(nodeValidationIssue); + } + } + + for (String warning : warnings) { + NodeValidationIssue nodeValidationIssue = toIssue(warning, WorkflowIssueSeverity.WARNING); + + if (nodeValidationIssue != null) { + nodeValidationIssues.add(nodeValidationIssue); + } + } + + return nodeValidationIssues; + } + + @Nullable + private static NodeValidationIssue toIssue(String line, WorkflowIssueSeverity severity) { + Matcher prefixMatcher = TASK_PREFIX_PATTERN.matcher(line); + + if (prefixMatcher.matches()) { + return classify(prefixMatcher.group(1), prefixMatcher.group(2), severity); + } + + Matcher duplicateMatcher = DUPLICATE_NODE_NAME_PATTERN.matcher(line); + + if (duplicateMatcher.matches()) { + return new NodeValidationIssue( + duplicateMatcher.group(1), null, WorkflowIssueKind.DUPLICATE_NODE_NAME, severity, line); + } + + return null; + } + + private static NodeValidationIssue classify(String nodeName, String message, WorkflowIssueSeverity severity) { + for (MessageTemplate messageTemplate : MESSAGE_TEMPLATES) { + Matcher matcher = messageTemplate.pattern() + .matcher(message); + + if (matcher.matches()) { + return new NodeValidationIssue(nodeName, matcher.group(1), messageTemplate.kind(), severity, message); + } + } + + return new NodeValidationIssue(nodeName, null, WorkflowIssueKind.OTHER, severity, message); + } + + private record MessageTemplate(Pattern pattern, WorkflowIssueKind kind) { + } +} diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/ObjectPropertyValidator.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/ObjectPropertyValidator.java index f405d422ce5..18db40b1e84 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/ObjectPropertyValidator.java +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/ObjectPropertyValidator.java @@ -18,8 +18,6 @@ import com.bytechef.commons.util.StringUtils; import com.bytechef.platform.workflow.validator.model.PropertyInfo; -import java.util.Collection; -import java.util.Iterator; import java.util.List; import tools.jackson.databind.JsonNode; @@ -57,22 +55,6 @@ static void validate( if (nestedProperties != null && !nestedProperties.isEmpty()) { PropertyValidator.validateProperties( valueJsonNode, nestedProperties, propertyPath, originalCurrentParameters, errors, warnings); - } else { - generateWarningsForUndefinedNestedProperties(valueJsonNode, propertyPath, warnings); } } - - private static void generateWarningsForUndefinedNestedProperties( - JsonNode valueJsonNode, String propertyPath, StringBuilder warnings) { - - Collection propertyNames = valueJsonNode.propertyNames(); - - Iterator propertyNamesIterator = propertyNames.iterator(); - - propertyNamesIterator.forEachRemaining(curPropertyName -> { - String curPropertyPath = PropertyUtils.buildPropertyPath(propertyPath, curPropertyName); - - StringUtils.appendWithNewline(ValidationErrorUtils.notDefined(curPropertyPath), warnings); - }); - } } diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/PropertyValidator.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/PropertyValidator.java index a64c82af147..66118b034af 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/PropertyValidator.java +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/PropertyValidator.java @@ -51,18 +51,16 @@ public static void validateProperties( return; } - Set validatedPropertyNames = new HashSet<>(); + Set definedPropertyNames = new HashSet<>(); for (PropertyInfo propertyInfo : propertyInfos) { - ValidationResult result = validatePropertyWithDisplayCondition( + validatePropertyWithDisplayCondition( taskParametersJsonNode, propertyInfo, path, originalCurrentParameters, errors, warnings); - if (result.wasProcessed()) { - validatedPropertyNames.add(propertyInfo.name()); - } + definedPropertyNames.add(propertyInfo.name()); } - checkForUndefinedProperties(taskParametersJsonNode, validatedPropertyNames, path, warnings); + checkForUndefinedProperties(taskParametersJsonNode, definedPropertyNames, path, warnings); } private static ValidationResult validatePropertyWithDisplayCondition( @@ -188,7 +186,9 @@ private static void validateProperty( boolean isRequired = propertyInfo.required(); - if (!taskParametersJsonNode.has(fieldName)) { + if (!taskParametersJsonNode.has(fieldName) || + (!"NULL".equalsIgnoreCase(type) && isEmptyValue(taskParametersJsonNode.get(fieldName)))) { + if (isRequired) { StringUtils.appendWithNewline(ValidationErrorUtils.missingProperty(propertyPath), errors); @@ -209,6 +209,11 @@ private static void validateProperty( validatePropertyByType(valueJsonNode, propertyInfo, propertyPath, originalCurrentParameters, errors, warnings); } + private static boolean isEmptyValue(JsonNode valueJsonNode) { + return valueJsonNode.isNull() || (valueJsonNode.isString() && org.apache.commons.lang3.StringUtils.isBlank( + valueJsonNode.asString())); + } + private static void validatePropertyByType( JsonNode valueJsonNode, PropertyInfo propertyInfo, String propertyPath, String originalCurrentParameters, StringBuilder errors, StringBuilder warnings) { diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/ResourceReferenceValidator.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/ResourceReferenceValidator.java new file mode 100644 index 00000000000..d9ac63314a6 --- /dev/null +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/ResourceReferenceValidator.java @@ -0,0 +1,90 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.workflow.validator; + +import com.bytechef.commons.util.StringUtils; +import com.bytechef.platform.workflow.validator.model.PropertyInfo; +import java.util.List; +import org.jspecify.annotations.Nullable; +import tools.jackson.databind.JsonNode; + +/** + * @author Ivica Cardic + */ +class ResourceReferenceValidator { + + private ResourceReferenceValidator() { + } + + static void validate( + @Nullable JsonNode parametersJsonNode, List taskDefinition, String pathPrefix, + WorkflowValidator.ResourceReferenceProvider resourceReferenceProvider, StringBuilder errors, + StringBuilder warnings) { + + if (parametersJsonNode == null || !parametersJsonNode.isObject()) { + return; + } + + for (PropertyInfo propertyInfo : taskDefinition) { + String propertyName = propertyInfo.name(); + + if (propertyName == null || !parametersJsonNode.has(propertyName)) { + continue; + } + + JsonNode valueJsonNode = parametersJsonNode.get(propertyName); + String propertyPath = pathPrefix.isEmpty() ? propertyName : pathPrefix + "." + propertyName; + + if (propertyInfo.resourceType() != null) { + validateReference( + valueJsonNode, propertyPath, propertyInfo.resourceType(), resourceReferenceProvider, errors, + warnings); + } else if ("OBJECT".equalsIgnoreCase(propertyInfo.type()) && propertyInfo.nestedProperties() != null) { + validate( + valueJsonNode, propertyInfo.nestedProperties(), propertyPath, resourceReferenceProvider, errors, + warnings); + } + } + } + + private static void validateReference( + JsonNode valueJsonNode, String propertyPath, String resourceType, + WorkflowValidator.ResourceReferenceProvider resourceReferenceProvider, StringBuilder errors, + StringBuilder warnings) { + + if (!valueJsonNode.isValueNode() || valueJsonNode.isNull()) { + return; + } + + String reference = valueJsonNode.asString(); + + if (reference.isBlank() || reference.contains("${")) { + return; + } + + try { + String problem = resourceReferenceProvider.findProblem(resourceType, reference); + + if (problem != null) { + StringUtils.appendWithNewline(ValidationErrorUtils.missingResource(propertyPath, problem), errors); + } + } catch (RuntimeException e) { + StringUtils.appendWithNewline( + ValidationErrorUtils.resourceCheckFailed(propertyPath, e.getMessage()), warnings); + } + } +} diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/TaskValidator.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/TaskValidator.java index 152aeef41f7..9667072f877 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/TaskValidator.java +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/TaskValidator.java @@ -19,8 +19,10 @@ import com.bytechef.commons.util.StringUtils; import com.bytechef.platform.workflow.validator.model.PropertyInfo; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.jspecify.annotations.Nullable; @@ -72,7 +74,9 @@ public static void validateAllTasks(ValidationContext context) { processTaskDispatcher(taskJsonNode, context); validateDataPills(taskJsonNode, taskDefinition, context); - validateClusterElements(taskJsonNode, taskName, context); + validateClusterElements(taskJsonNode, "", context); + + removeDynamicPropertyWarnings(taskJsonNode, warnings, warningsStart); if (!taskName.isEmpty()) { prefixTaskMessages(errors, errorsStart, taskName); @@ -81,6 +85,60 @@ public static void validateAllTasks(ValidationContext context) { } } + private static void removeDynamicPropertyWarnings( + JsonNode taskJsonNode, StringBuilder warnings, int warningsStart) { + + Set dynamicPropertyPaths = getDynamicPropertyPaths(taskJsonNode); + + if (dynamicPropertyPaths.isEmpty() || warnings.length() <= warningsStart) { + return; + } + + Set dynamicPropertyWarnings = new LinkedHashSet<>(); + + for (String dynamicPropertyPath : dynamicPropertyPaths) { + dynamicPropertyWarnings.add(ValidationErrorUtils.notDefined(dynamicPropertyPath)); + } + + String content = warnings.substring(warningsStart); + + warnings.delete(warningsStart, warnings.length()); + + if (content.startsWith("\n")) { + content = content.substring(1); + } + + for (String line : content.split("\n", -1)) { + if (line.isEmpty() || dynamicPropertyWarnings.contains(line)) { + continue; + } + + StringUtils.appendWithNewline(line, warnings); + } + } + + private static Set getDynamicPropertyPaths(JsonNode taskJsonNode) { + JsonNode metadataJsonNode = taskJsonNode.get("metadata"); + + if (metadataJsonNode == null || !metadataJsonNode.isObject()) { + return Set.of(); + } + + JsonNode uiJsonNode = metadataJsonNode.get("ui"); + + if (uiJsonNode == null || !uiJsonNode.isObject()) { + return Set.of(); + } + + JsonNode dynamicPropertyTypesJsonNode = uiJsonNode.get("dynamicPropertyTypes"); + + if (dynamicPropertyTypesJsonNode == null || !dynamicPropertyTypesJsonNode.isObject()) { + return Set.of(); + } + + return new LinkedHashSet<>(dynamicPropertyTypesJsonNode.propertyNames()); + } + private static void prefixTaskMessages(StringBuilder builder, int startPosition, String taskName) { if (builder.length() <= startPosition) { return; @@ -113,23 +171,37 @@ private static void validateClusterElements(JsonNode taskJsonNode, String parent for (String fieldName : clusterElementsJsonNode.propertyNames()) { JsonNode clusterElementJsonNode = clusterElementsJsonNode.get(fieldName); - if (clusterElementJsonNode == null || !clusterElementJsonNode.isObject()) { + if (clusterElementJsonNode == null) { continue; } - if (!clusterElementJsonNode.has("type") || !clusterElementJsonNode.has("name")) { - continue; + if (clusterElementJsonNode.isArray()) { + for (JsonNode clusterElementItemJsonNode : clusterElementJsonNode) { + validateClusterElement(clusterElementItemJsonNode, parentPath, context); + } + } else { + validateClusterElement(clusterElementJsonNode, parentPath, context); } + } + } - JsonNode nameJsonNode = clusterElementJsonNode.get("name"); - - String elementName = nameJsonNode.asString(); + private static void validateClusterElement( + JsonNode clusterElementJsonNode, String parentPath, ValidationContext context) { - String elementPath = PropertyUtils.buildPropertyPath(parentPath, elementName); + if (!clusterElementJsonNode.isObject() || !clusterElementJsonNode.has("type") || + !clusterElementJsonNode.has("name")) { - validateClusterElementParameters(clusterElementJsonNode, elementPath, context); - validateClusterElements(clusterElementJsonNode, elementPath, context); + return; } + + JsonNode nameJsonNode = clusterElementJsonNode.get("name"); + + String elementName = nameJsonNode.asString(); + + String elementPath = PropertyUtils.buildPropertyPath(parentPath, elementName); + + validateClusterElementParameters(clusterElementJsonNode, elementPath, context); + validateClusterElements(clusterElementJsonNode, elementPath, context); } private static void checkClusterElementKeys(JsonNode taskJsonNode, ValidationContext context) { @@ -143,12 +215,14 @@ private static void checkClusterElementKeys(JsonNode taskJsonNode, ValidationCon Map> clusterTypesProviderMap = context.getClusterTypesProviderMap(); - List requiredKeys = clusterTypesProviderMap.get(taskType); + List clusterElementKeys = clusterTypesProviderMap.get(taskType); - if (requiredKeys == null || requiredKeys.isEmpty()) { + if (clusterElementKeys == null || clusterElementKeys.isEmpty()) { return; } + List requiredKeys = context.getRequiredClusterElementTypes(taskType); + boolean isVectorStore = taskType.endsWith("/vectorStore"); String taskName = taskJsonNode.has("name") ? taskJsonNode.get("name") .asString() : ""; @@ -169,7 +243,7 @@ private static void checkClusterElementKeys(JsonNode taskJsonNode, ValidationCon if (clusterElementsJsonNode != null && clusterElementsJsonNode.isObject()) { for (String presentKey : clusterElementsJsonNode.propertyNames()) { - if (!requiredKeys.contains(presentKey)) { + if (!clusterElementKeys.contains(presentKey)) { StringUtils.appendWithNewline( ValidationErrorUtils.undefinedClusterElement(presentKey, taskName), context.getWarnings()); @@ -204,6 +278,10 @@ private static void validateClusterElementParameters( parametersNode, elementDefinition, elementPath, parameters, context.getErrors(), new StringBuilder()); } + ResourceReferenceValidator.validate( + parametersJsonNode, elementDefinition, "", context.getResourceReferenceProvider(), context.getErrors(), + context.getWarnings()); + DataPillValidator.validateTaskDataPills(clusterElementJsonNode, context, elementDefinition, true); } @@ -458,11 +536,12 @@ private static void processNestedTaskArray(JsonNode taskArrayJsonNode, Validatio boolean isInMainLoop = context.getAllTasksMap() .containsKey(nestedTaskName); - processIndividualNestedTask(nestedTaskJsonNode, context); - - if (!isInMainLoop) { - validateClusterElements(nestedTaskJsonNode, nestedTaskName, context); + if (isInMainLoop) { + continue; } + + processIndividualNestedTask(nestedTaskJsonNode, context); + validateClusterElements(nestedTaskJsonNode, nestedTaskName, context); } } } @@ -598,6 +677,10 @@ private static List validateTaskParameters(JsonNode taskJsonNode, } validateTaskParameters(taskParameters, taskDefinition, context.getErrors(), context.getWarnings()); + + ResourceReferenceValidator.validate( + jsonNode, taskDefinition, "", context.getResourceReferenceProvider(), context.getErrors(), + context.getWarnings()); } return taskDefinition; diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/ValidationContext.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/ValidationContext.java index 150b3deb626..0a5d7372323 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/ValidationContext.java +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/ValidationContext.java @@ -21,6 +21,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.jspecify.annotations.Nullable; import tools.jackson.databind.JsonNode; /** @@ -37,6 +38,8 @@ class ValidationContext { private final Map taskOutputMap; private final Map nodeOutputMap; private final Map> clusterTypesProviderMap; + private final WorkflowValidator.@Nullable ClusterTypesProvider clusterTypesProvider; + private final WorkflowValidator.ResourceReferenceProvider resourceReferenceProvider; private final StringBuilder errors; private final StringBuilder warnings; private final List taskNames = new ArrayList<>(); @@ -47,7 +50,9 @@ private ValidationContext( List taskJsonNodes, List inputJsonNodes, Map> taskDefinitionMap, Map taskOutputMap, Map nodeOutputMap, Map> clusterTypesProviderMap, - StringBuilder errors, StringBuilder warnings) { + WorkflowValidator.@Nullable ClusterTypesProvider clusterTypesProvider, + WorkflowValidator.ResourceReferenceProvider resourceReferenceProvider, StringBuilder errors, + StringBuilder warnings) { this.taskJsonNodes = taskJsonNodes; this.inputJsonNodes = inputJsonNodes; @@ -55,6 +60,8 @@ private ValidationContext( this.taskOutputMap = taskOutputMap; this.nodeOutputMap = nodeOutputMap; this.clusterTypesProviderMap = clusterTypesProviderMap; + this.clusterTypesProvider = clusterTypesProvider; + this.resourceReferenceProvider = resourceReferenceProvider; this.errors = errors; this.warnings = warnings; @@ -76,17 +83,31 @@ public static ValidationContext of( return of( taskJsonNodes, List.of(), taskDefinitionMap, taskOutputMap, nodeOutputMap, clusterTypesProviderMap, - errors, warnings); + WorkflowValidator.NO_RESOURCE_REFERENCE_PROVIDER, errors, warnings); } public static ValidationContext of( List taskJsonNodes, List inputJsonNodes, Map> taskDefinitionMap, Map taskOutputMap, Map nodeOutputMap, Map> clusterTypesProviderMap, - StringBuilder errors, StringBuilder warnings) { + WorkflowValidator.ResourceReferenceProvider resourceReferenceProvider, StringBuilder errors, + StringBuilder warnings) { + + return of( + taskJsonNodes, inputJsonNodes, taskDefinitionMap, taskOutputMap, nodeOutputMap, clusterTypesProviderMap, + null, resourceReferenceProvider, errors, warnings); + } + + public static ValidationContext of( + List taskJsonNodes, List inputJsonNodes, + Map> taskDefinitionMap, Map taskOutputMap, + Map nodeOutputMap, Map> clusterTypesProviderMap, + WorkflowValidator.@Nullable ClusterTypesProvider clusterTypesProvider, + WorkflowValidator.ResourceReferenceProvider resourceReferenceProvider, StringBuilder errors, + StringBuilder warnings) { return new ValidationContext(taskJsonNodes, inputJsonNodes, taskDefinitionMap, taskOutputMap, nodeOutputMap, - clusterTypesProviderMap, errors, warnings); + clusterTypesProviderMap, clusterTypesProvider, resourceReferenceProvider, errors, warnings); } private void buildTaskMaps() { @@ -155,4 +176,20 @@ public Map getAllTasksMap() { public Map> getClusterTypesProviderMap() { return clusterTypesProviderMap; } + + public List getRequiredClusterElementTypes(String taskType) { + List clusterElementTypes = clusterTypesProviderMap.getOrDefault(taskType, List.of()); + + if (clusterTypesProvider == null) { + return clusterElementTypes; + } + + List requiredClusterElementTypes = clusterTypesProvider.getRequiredClusterElementTypes(taskType); + + return requiredClusterElementTypes == null ? clusterElementTypes : requiredClusterElementTypes; + } + + public WorkflowValidator.ResourceReferenceProvider getResourceReferenceProvider() { + return resourceReferenceProvider; + } } diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/ValidationErrorUtils.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/ValidationErrorUtils.java index 924d8e90b24..d236bf71fc0 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/ValidationErrorUtils.java +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/ValidationErrorUtils.java @@ -58,6 +58,14 @@ public static String missingProperty(String propertyPath) { return "Missing required property: " + propertyPath; } + public static String missingResource(String propertyPath, String reason) { + return "Resource referenced by property '" + propertyPath + "' is not available: " + reason; + } + + public static String resourceCheckFailed(String propertyPath, String reason) { + return "Could not verify the resource referenced by property '" + propertyPath + "': " + reason; + } + /** * Creates property not defined a warning message. */ diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/WorkflowValidator.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/WorkflowValidator.java index 304711c877a..c205c7bd3be 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/WorkflowValidator.java +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/WorkflowValidator.java @@ -103,6 +103,18 @@ public static void validateWorkflow( Map taskOutputMap, Map nodeOutputMap, Map> clusterTypesMap, StringBuilder errors, StringBuilder warnings) { + validateWorkflow( + workflow, taskDefinitionProvider, taskOutputProvider, clusterTypesProvider, NO_RESOURCE_REFERENCE_PROVIDER, + taskDefinitionMap, taskOutputMap, nodeOutputMap, clusterTypesMap, errors, warnings); + } + + public static void validateWorkflow( + String workflow, TaskDefinitionProvider taskDefinitionProvider, TaskOutputProvider taskOutputProvider, + @Nullable ClusterTypesProvider clusterTypesProvider, ResourceReferenceProvider resourceReferenceProvider, + Map> taskDefinitionMap, Map taskOutputMap, + Map nodeOutputMap, Map> clusterTypesMap, StringBuilder errors, + StringBuilder warnings) { + try { validateWorkflowStructure(workflow, errors, warnings); @@ -125,7 +137,7 @@ public static void validateWorkflow( taskOutputMap, clusterTypesMap, workflowJsonNode, taskJsonNodes, errors, warnings); validateWorkflowTasks( taskJsonNodes, inputJsonNodes, taskDefinitionMap, taskOutputMap, nodeOutputMap, clusterTypesMap, - errors, warnings); + clusterTypesProvider, resourceReferenceProvider, errors, warnings); } catch (Exception e) { errors.append("Failed to validate workflow: "); errors.append(e.getMessage() @@ -157,18 +169,19 @@ public static void validateWorkflowTasks( Map> clusterTypesProviderMap, StringBuilder errors, StringBuilder warnings) { validateWorkflowTasks( - taskJsonNodes, List.of(), taskDefinitionMap, taskOutput, nodeOutputMap, clusterTypesProviderMap, errors, - warnings); + taskJsonNodes, List.of(), taskDefinitionMap, taskOutput, nodeOutputMap, clusterTypesProviderMap, null, + NO_RESOURCE_REFERENCE_PROVIDER, errors, warnings); } private static void validateWorkflowTasks( List taskJsonNodes, List inputJsonNodes, Map> taskDefinitionMap, Map taskOutput, Map nodeOutputMap, - Map> clusterTypesProviderMap, StringBuilder errors, StringBuilder warnings) { + Map> clusterTypesProviderMap, @Nullable ClusterTypesProvider clusterTypesProvider, + ResourceReferenceProvider resourceReferenceProvider, StringBuilder errors, StringBuilder warnings) { ValidationContext context = ValidationContext.of( taskJsonNodes, inputJsonNodes, taskDefinitionMap, taskOutput, nodeOutputMap, clusterTypesProviderMap, - errors, warnings); + clusterTypesProvider, resourceReferenceProvider, errors, warnings); TaskValidator.validateAllTasks(context); } @@ -394,9 +407,7 @@ private static void processClusterElements( taskOutputProvider.getTaskOutputProperty(type, "clusterElement", warnings)); } } - } else if (clusterElementJsonNode.isObject() && - clusterElementJsonNode.has("clusterElements") && clusterElementJsonNode.has("type")) { - + } else if (clusterElementJsonNode.isObject() && clusterElementJsonNode.has("type")) { JsonNode typeJsonNode = clusterElementJsonNode.get("type"); String type = typeJsonNode.asString(); @@ -405,6 +416,10 @@ private static void processClusterElements( taskOutputMap.putIfAbsent( type, taskOutputProvider.getTaskOutputProperty(type, "clusterElement", warnings)); + if (!clusterElementJsonNode.has("clusterElements")) { + continue; + } + List clusterElementTypes = clusterTypesProvider.getClusterElementTypes(type); if (clusterElementTypes != null) { @@ -839,5 +854,19 @@ public interface TaskOutputProvider { public interface ClusterTypesProvider { @Nullable List getClusterElementTypes(String taskType); + + @Nullable + default List getRequiredClusterElementTypes(String taskType) { + return getClusterElementTypes(taskType); + } + } + + @FunctionalInterface + public interface ResourceReferenceProvider { + + @Nullable + String findProblem(String resourceType, String reference); } + + public static final ResourceReferenceProvider NO_RESOURCE_REFERENCE_PROVIDER = (resourceType, reference) -> null; } diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/WorkflowValidatorFacadeImpl.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/WorkflowValidatorFacadeImpl.java index 84f9eff5f28..84f596fc473 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/WorkflowValidatorFacadeImpl.java +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/main/java/com/bytechef/platform/workflow/validator/WorkflowValidatorFacadeImpl.java @@ -21,6 +21,7 @@ import com.bytechef.commons.util.CollectionUtils; import com.bytechef.commons.util.JsonUtils; import com.bytechef.component.definition.ClusterElementDefinition; +import com.bytechef.definition.BaseProperty.ResourceType; import com.bytechef.platform.component.domain.ActionDefinition; import com.bytechef.platform.component.domain.ArrayProperty; import com.bytechef.platform.component.domain.ComponentDefinition; @@ -34,6 +35,7 @@ import com.bytechef.platform.component.service.ClusterElementDefinitionService; import com.bytechef.platform.component.service.ComponentDefinitionService; import com.bytechef.platform.component.service.TriggerDefinitionService; +import com.bytechef.platform.configuration.domain.Environment; import com.bytechef.platform.definition.WorkflowNodeType; import com.bytechef.platform.domain.BaseProperty; import com.bytechef.platform.domain.OutputResponse; @@ -46,6 +48,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; @@ -66,11 +70,28 @@ public class WorkflowValidatorFacadeImpl implements WorkflowValidatorFacade { private final ActionDefinitionService actionDefinitionService; private final ClusterElementDefinitionService clusterElementDefinitionService; private final ComponentDefinitionService componentDefinitionService; + private final Map resourceReferenceResolverMap; private final TaskDispatcherDefinitionService taskDispatcherDefinitionService; private final TriggerDefinitionFacade triggerDefinitionFacade; private final TriggerDefinitionService triggerDefinitionService; private final WorkflowService workflowService; + private final WorkflowValidator.ClusterTypesProvider clusterTypesProvider = + new WorkflowValidator.ClusterTypesProvider() { + + @Override + @Nullable + public List getClusterElementTypes(String taskType) { + return getClusterElementTypeKeys(taskType, false); + } + + @Override + @Nullable + public List getRequiredClusterElementTypes(String taskType) { + return getClusterElementTypeKeys(taskType, true); + } + }; + @SuppressFBWarnings("EI2") public WorkflowValidatorFacadeImpl( ActionDefinitionFacade actionDefinitionFacade, ActionDefinitionService actionDefinitionService, @@ -78,7 +99,7 @@ public WorkflowValidatorFacadeImpl( ComponentDefinitionService componentDefinitionService, TaskDispatcherDefinitionService taskDispatcherDefinitionService, TriggerDefinitionFacade triggerDefinitionFacade, TriggerDefinitionService triggerDefinitionService, - WorkflowService workflowService) { + WorkflowService workflowService, List resourceReferenceResolvers) { this.actionDefinitionFacade = actionDefinitionFacade; this.actionDefinitionService = actionDefinitionService; @@ -88,16 +109,24 @@ public WorkflowValidatorFacadeImpl( this.triggerDefinitionFacade = triggerDefinitionFacade; this.triggerDefinitionService = triggerDefinitionService; this.workflowService = workflowService; + this.resourceReferenceResolverMap = resourceReferenceResolvers.stream() + .collect(Collectors.toMap(ResourceReferenceResolver::getResourceType, Function.identity())); } @Override public WorkflowValidationResult validateWorkflow(String workflow) { + return validateWorkflow(workflow, Environment.DEVELOPMENT.ordinal()); + } + + @Override + public WorkflowValidationResult validateWorkflow(String workflow, long environmentId) { StringBuilder errors = new StringBuilder(); StringBuilder warnings = new StringBuilder(); WorkflowValidator.validateWorkflow( - workflow, this::getTaskProperties, this::getTaskOutputProperty, this::getClusterElementTypes, - new HashMap<>(), new HashMap<>(), buildNodeOutputMap(workflow), new HashMap<>(), errors, warnings); + workflow, this::getTaskProperties, this::getTaskOutputProperty, clusterTypesProvider, + createResourceReferenceProvider(resourceReferenceResolverMap, environmentId), new HashMap<>(), + new HashMap<>(), buildNodeOutputMap(workflow), new HashMap<>(), errors, warnings); String errorsString = errors.toString(); @@ -111,14 +140,32 @@ public WorkflowValidationResult validateWorkflow(String workflow) { .filter(line -> !line.isBlank()) .toList(); - return new WorkflowValidationResult(errorList, warningList); + return new WorkflowValidationResult( + errorList, warningList, NodeValidationIssueParser.parse(errorList, warningList)); } @Override public WorkflowValidationResult validateWorkflowById(String workflowId) { + return validateWorkflowById(workflowId, Environment.DEVELOPMENT.ordinal()); + } + + @Override + public WorkflowValidationResult validateWorkflowById(String workflowId, long environmentId) { Workflow workflow = workflowService.getWorkflow(workflowId); - return validateWorkflow(workflow.getDefinition()); + return validateWorkflow(workflow.getDefinition(), environmentId); + } + + static WorkflowValidator.ResourceReferenceProvider createResourceReferenceProvider( + Map resourceReferenceResolverMap, long environmentId) { + + return (resourceType, reference) -> { + ResourceReferenceResolver resourceReferenceResolver = + resourceReferenceResolverMap.get(ResourceType.valueOf(resourceType)); + + return resourceReferenceResolver == null + ? null : resourceReferenceResolver.findProblem(reference, environmentId); + }; } @Override @@ -304,7 +351,7 @@ private void addNodeOutputs(@Nullable JsonNode nodesJsonNode, boolean trigger, M } @Nullable - private List getClusterElementTypes(String taskType) { + private List getClusterElementTypeKeys(String taskType, boolean requiredOnly) { WorkflowNodeType workflowNodeType = WorkflowNodeType.ofType(taskType); if (workflowNodeType.operation() != null) { @@ -312,19 +359,26 @@ private List getClusterElementTypes(String taskType) { componentDefinitionService.getComponentDefinition(workflowNodeType.name(), workflowNodeType.version()); if (componentDefinition.isClusterElement()) { - return componentDefinition.getClusterElementTypes() - .stream() - .map(ClusterElementDefinition.ClusterElementType::key) - .toList(); + return toClusterElementTypeKeys(componentDefinition.getClusterElementTypes(), requiredOnly); } } return null; } + static List toClusterElementTypeKeys( + List clusterElementTypes, boolean requiredOnly) { + + return clusterElementTypes.stream() + .filter(clusterElementType -> !requiredOnly || clusterElementType.required()) + .map(ClusterElementDefinition.ClusterElementType::key) + .toList(); + } + private static PropertyInfo toPropertyInfo(BaseProperty baseProperty) { String type; List nestedPropertyInfos = null; + String resourceType = null; switch (baseProperty) { case ObjectProperty objectProperty -> { @@ -361,6 +415,12 @@ private static PropertyInfo toPropertyInfo(BaseProperty baseProperty) { com.bytechef.component.definition.Property.Type propertyType = property.getType(); type = propertyType.name(); + + ResourceType propertyResourceType = property.getResourceType(); + + if (propertyResourceType != null) { + resourceType = propertyResourceType.name(); + } } case com.bytechef.platform.workflow.task.dispatcher.domain.ObjectProperty objectProperty -> { type = "OBJECT"; @@ -407,6 +467,7 @@ private static PropertyInfo toPropertyInfo(BaseProperty baseProperty) { return new PropertyInfo( baseProperty.getName(), type, baseProperty.getDescription(), baseProperty.getRequired(), - baseProperty.getExpressionEnabled(), baseProperty.getDisplayCondition(), nestedPropertyInfos); + baseProperty.getExpressionEnabled(), baseProperty.getDisplayCondition(), null, nestedPropertyInfos, + resourceType); } } diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/NodeValidationIssueParserTest.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/NodeValidationIssueParserTest.java new file mode 100644 index 00000000000..7473c8fbe45 --- /dev/null +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/NodeValidationIssueParserTest.java @@ -0,0 +1,131 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.workflow.validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.bytechef.platform.workflow.validator.WorkflowValidatorFacade.NodeValidationIssue; +import com.bytechef.platform.workflow.validator.WorkflowValidatorFacade.WorkflowIssueKind; +import com.bytechef.platform.workflow.validator.WorkflowValidatorFacade.WorkflowIssueSeverity; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * @author Ivica Cardic + */ +class NodeValidationIssueParserTest { + + @Test + void classifiesPrefixedMessagesByTemplate() { + List issues = NodeValidationIssueParser.parse( + List.of( + "[dataTable_1] Missing required property: table", + "[dataTable_1] Resource referenced by property 'table' is not available: gone", + "[condition_1] Property 'python_1.diff' does not exist in the output of 'python/v1/script'", + "[condition_1] Wrong task order: You can't reference 'logger_1.x' in condition_1", + "[http_1] Property 'timeout' has incorrect type. Expected: INTEGER, but got: STRING", + "[loop_1] Property 'loop_1.item[0].propBool' in output of 'loop/v1' is of type boolean, not number", + "[agent_1] Cluster element 'model' is missing from task agent_1", + "[x_1] Something the parser has never seen"), + List.of( + "[dataTable_1] Could not verify the resource referenced by property 'table': db down", + "[condition_1] Property 'python_1.diff' might not exist in the output of 'python/v1/script'")); + + assertEquals(10, issues.size()); + assertIssue(issues.get(0), "dataTable_1", "table", WorkflowIssueKind.MISSING_REQUIRED, + WorkflowIssueSeverity.ERROR); + assertIssue(issues.get(1), "dataTable_1", "table", WorkflowIssueKind.MISSING_RESOURCE, + WorkflowIssueSeverity.ERROR); + assertIssue(issues.get(2), "condition_1", "python_1.diff", WorkflowIssueKind.BROKEN_REFERENCE, + WorkflowIssueSeverity.ERROR); + assertIssue(issues.get(3), "condition_1", "logger_1.x", WorkflowIssueKind.TASK_ORDER, + WorkflowIssueSeverity.ERROR); + assertIssue(issues.get(4), "http_1", "timeout", WorkflowIssueKind.TYPE_MISMATCH, WorkflowIssueSeverity.ERROR); + assertIssue(issues.get(5), "loop_1", "loop_1.item[0].propBool", WorkflowIssueKind.TYPE_MISMATCH, + WorkflowIssueSeverity.ERROR); + assertIssue(issues.get(6), "agent_1", "model", WorkflowIssueKind.MISSING_CLUSTER_ELEMENT, + WorkflowIssueSeverity.ERROR); + assertIssue(issues.get(7), "x_1", null, WorkflowIssueKind.OTHER, WorkflowIssueSeverity.ERROR); + assertIssue(issues.get(8), "dataTable_1", "table", WorkflowIssueKind.MISSING_RESOURCE, + WorkflowIssueSeverity.WARNING); + assertIssue(issues.get(9), "condition_1", "python_1.diff", WorkflowIssueKind.BROKEN_REFERENCE, + WorkflowIssueSeverity.WARNING); + assertEquals("Something the parser has never seen", issues.get(7) + .message()); + } + + @Test + void classifiesFieldTaskDateAndDisplayConditionMessages() { + List issues = NodeValidationIssueParser.parse( + List.of( + "[testTask] Field 'label' must be a string", + "[task_2] Task 'ghost_1.value' doesn't exist.", + "[task_1] Input property 'flag' is of type boolean, not integer", + "[testTask] Property 'startDate' is in incorrect date format. Format should be in: 'yyyy-MM-dd'", + "[testTask] Property 'items' does not match any of the expected union types: STRING, NUMBER"), + List.of( + "[testTask] Missing recommended field: label", + "[testTask] Invalid logic for display condition: 'foo =='")); + + assertEquals(7, issues.size()); + assertIssue(issues.get(0), "testTask", "label", WorkflowIssueKind.TYPE_MISMATCH, WorkflowIssueSeverity.ERROR); + assertIssue(issues.get(1), "task_2", "ghost_1.value", WorkflowIssueKind.BROKEN_REFERENCE, + WorkflowIssueSeverity.ERROR); + assertIssue(issues.get(2), "task_1", "flag", WorkflowIssueKind.TYPE_MISMATCH, WorkflowIssueSeverity.ERROR); + assertIssue(issues.get(3), "testTask", "startDate", WorkflowIssueKind.TYPE_MISMATCH, + WorkflowIssueSeverity.ERROR); + assertIssue(issues.get(4), "testTask", "items", WorkflowIssueKind.TYPE_MISMATCH, WorkflowIssueSeverity.ERROR); + assertIssue(issues.get(5), "testTask", "label", WorkflowIssueKind.MISSING_REQUIRED, + WorkflowIssueSeverity.WARNING); + assertIssue(issues.get(6), "testTask", "'foo =='", WorkflowIssueKind.OTHER, WorkflowIssueSeverity.WARNING); + } + + @Test + void duplicateNodeNameIsAttributedToTheDuplicatedName() { + List issues = NodeValidationIssueParser.parse( + List.of("Node names must be unique. Duplicate node name: logger_1"), List.of()); + + assertEquals(1, issues.size()); + assertIssue(issues.get(0), "logger_1", null, WorkflowIssueKind.DUPLICATE_NODE_NAME, + WorkflowIssueSeverity.ERROR); + } + + @Test + void workflowLevelMessagesWithoutNodeAreDropped() { + List issues = NodeValidationIssueParser.parse( + List.of("Failed to validate workflow: boom", "Missing required field: triggers"), List.of()); + + assertEquals(0, issues.size()); + } + + private static void assertIssue( + NodeValidationIssue issue, String nodeName, String propertyPath, WorkflowIssueKind kind, + WorkflowIssueSeverity severity) { + + assertEquals(nodeName, issue.nodeName()); + + if (propertyPath == null) { + assertNull(issue.propertyPath()); + } else { + assertEquals(propertyPath, issue.propertyPath()); + } + + assertEquals(kind, issue.kind()); + assertEquals(severity, issue.severity()); + } +} diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/PropertyInfoTest.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/PropertyInfoTest.java new file mode 100644 index 00000000000..a5b7928451e --- /dev/null +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/PropertyInfoTest.java @@ -0,0 +1,47 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.workflow.validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.bytechef.platform.workflow.validator.model.PropertyInfo; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * @author Ivica Cardic + */ +class PropertyInfoTest { + + @Test + void legacyConstructorsLeaveResourceTypeNull() { + PropertyInfo sevenArguments = new PropertyInfo("table", "STRING", null, true, true, null, null); + PropertyInfo eightArguments = new PropertyInfo("table", "STRING", null, true, true, null, List.of(), null); + + assertNull(sevenArguments.resourceType()); + assertNull(eightArguments.resourceType()); + } + + @Test + void canonicalConstructorKeepsResourceType() { + PropertyInfo propertyInfo = + new PropertyInfo("table", "STRING", null, true, true, null, null, null, "DATA_TABLE"); + + assertEquals("DATA_TABLE", propertyInfo.resourceType()); + } +} diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/ResourceReferenceValidatorTest.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/ResourceReferenceValidatorTest.java new file mode 100644 index 00000000000..fe95b7d0818 --- /dev/null +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/ResourceReferenceValidatorTest.java @@ -0,0 +1,285 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.workflow.validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import com.bytechef.commons.util.JsonUtils; +import com.bytechef.platform.workflow.validator.model.PropertyInfo; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +/** + * @author Ivica Cardic + */ +class ResourceReferenceValidatorTest { + + private static final PropertyInfo TABLE_PROPERTY = + new PropertyInfo("table", "STRING", null, true, true, null, null, null, "DATA_TABLE"); + private static final PropertyInfo NAME_PROPERTY = + new PropertyInfo("name", "STRING", null, false, true, null, null, null, null); + + @BeforeAll + static void beforeAll() { + JsonUtils.setObjectMapper(JsonMapper.builder() + .build()); + } + + @Test + void resolvedReferenceProducesNothing() { + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + ResourceReferenceValidator.validate( + JsonUtils.readTree("{\"table\":\"conversations\"}"), List.of(TABLE_PROPERTY), "", + (resourceType, reference) -> null, errors, warnings); + + assertEquals("", errors.toString()); + assertEquals("", warnings.toString()); + } + + @Test + void missingReferenceProducesError() { + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + ResourceReferenceValidator.validate( + JsonUtils.readTree("{\"table\":\"conversations\"}"), List.of(TABLE_PROPERTY), "", + (resourceType, reference) -> "Data table '" + reference + "' does not exist in this environment", + errors, warnings); + + assertEquals( + "Resource referenced by property 'table' is not available: Data table 'conversations' does not exist " + + "in this environment", + errors.toString()); + assertEquals("", warnings.toString()); + } + + @Test + void expressionValueIsSkipped() { + StringBuilder errors = new StringBuilder(); + + ResourceReferenceValidator.validate( + JsonUtils.readTree("{\"table\":\"${trigger_1.tableName}\"}"), List.of(TABLE_PROPERTY), "", + (resourceType, reference) -> "must not be called", errors, new StringBuilder()); + + assertEquals("", errors.toString()); + } + + @Test + void propertyWithoutResourceTypeIsSkipped() { + StringBuilder errors = new StringBuilder(); + + ResourceReferenceValidator.validate( + JsonUtils.readTree("{\"name\":\"x\"}"), List.of(NAME_PROPERTY), "", + (resourceType, reference) -> "must not be called", errors, new StringBuilder()); + + assertEquals("", errors.toString()); + } + + @Test + void resolverExceptionProducesWarning() { + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + ResourceReferenceValidator.validate( + JsonUtils.readTree("{\"table\":\"conversations\"}"), List.of(TABLE_PROPERTY), "", + (resourceType, reference) -> { + throw new IllegalStateException("database unavailable"); + }, errors, warnings); + + assertEquals("", errors.toString()); + assertEquals( + "Could not verify the resource referenced by property 'table': database unavailable", + warnings.toString()); + } + + @Test + void nestedObjectPropertyIsChecked() { + PropertyInfo settings = new PropertyInfo( + "settings", "OBJECT", null, false, true, null, null, List.of(TABLE_PROPERTY), null); + StringBuilder errors = new StringBuilder(); + + ResourceReferenceValidator.validate( + JsonUtils.readTree("{\"settings\":{\"table\":\"orders\"}}"), List.of(settings), "", + (resourceType, reference) -> "missing", errors, new StringBuilder()); + + assertEquals("Resource referenced by property 'settings.table' is not available: missing", errors.toString()); + } + + @Test + void workflowValidationPrefixesResourceErrorWithTaskName() { + String workflow = """ + { + "label": "Test Workflow", + "description": "workflowDescription", + "triggers": [ + {"label": "Manual", "name": "trigger_1", "type": "manual/v1/manual", "parameters": {}} + ], + "tasks": [ + { + "label": "Update Record", + "name": "dataTable_1", + "type": "dataTable/v1/updateRecord", + "parameters": {"table": "conversations"} + } + ] + } + """; + + Map> taskDefinitionMap = Map.of( + "manual/v1/manual", List.of(), + "dataTable/v1/updateRecord", List.of(TABLE_PROPERTY)); + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + WorkflowValidator.validateWorkflow( + workflow, (taskType, kind) -> taskDefinitionMap.get(taskType), (taskType, kind, w) -> null, + taskType -> null, + (resourceType, reference) -> "DATA_TABLE".equals(resourceType) ? "Data table '" + reference + + "' does not exist in this environment" : null, + new HashMap<>(), new HashMap<>(), Map.of(), new HashMap<>(), errors, warnings); + + assertEquals( + "[dataTable_1] Resource referenced by property 'table' is not available: Data table 'conversations' " + + "does not exist in this environment", + errors.toString()); + assertEquals("", warnings.toString()); + } + + @Test + void validateWorkflowTasksClusterElementResourceReferenceMissing() { + String tasksJson = """ + [ + { + "clusterElements": { + "tool": { + "label": "Knowledge Base Search", + "name": "knowledgeBaseSearchTool_1", + "parameters": {"knowledgeBase": "42"}, + "type": "knowledgeBase/v1/searchTool" + } + }, + "name": "aiAgent_1", + "label": "AI Agent", + "parameters": {"userPrompt": "hi"}, + "type": "aiAgent/v1/chat" + } + ] + """; + + Map> taskDefinitionMap = Map.of( + "aiAgent/v1/chat", List.of( + new PropertyInfo("userPrompt", "STRING", null, true, true, null, null)), + "knowledgeBase/v1/searchTool", List.of( + new PropertyInfo("knowledgeBase", "STRING", null, true, true, null, null, null, "KNOWLEDGE_BASE"))); + + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + JsonNode tasksJsonNode = JsonUtils.readTree(tasksJson); + List taskJsonNodes = new ArrayList<>(); + + for (JsonNode taskJsonNode : tasksJsonNode) { + taskJsonNodes.add(taskJsonNode); + } + + ValidationContext context = ValidationContext.of( + taskJsonNodes, List.of(), taskDefinitionMap, Map.of(), Map.of(), Map.of(), + (resourceType, reference) -> "KNOWLEDGE_BASE".equals(resourceType) + ? "Knowledge base '" + reference + "' does not exist in this environment" : null, + errors, warnings); + + TaskValidator.validateAllTasks(context); + + assertEquals( + "[aiAgent_1] Resource referenced by property 'knowledgeBase' is not available: Knowledge base '42' " + + "does not exist in this environment", + errors.toString()); + assertEquals("", warnings.toString()); + } + + @Test + void validateWorkflowReportsNestedTaskResourceReferenceOnceAgainstNestedTask() { + String workflow = """ + { + "label": "Test Workflow", + "description": "workflowDescription", + "triggers": [ + {"label": "Manual", "name": "trigger_1", "type": "manual/v1/manual", "parameters": {}} + ], + "tasks": [ + { + "label": "Condition", + "name": "condition_1", + "type": "condition/v1", + "parameters": { + "caseTrue": [ + { + "label": "Update Record", + "name": "dataTable_1", + "type": "dataTable/v1/updateRecord", + "parameters": {"table": "conversations"} + } + ], + "caseFalse": [] + } + } + ] + } + """; + + Map> taskDefinitionMap = Map.of( + "manual/v1/manual", List.of(), + "condition/v1", List.of( + new PropertyInfo("caseTrue", "ARRAY", null, false, true, null, List.of( + new PropertyInfo(null, "TASK", null, false, false, null, null))), + new PropertyInfo("caseFalse", "ARRAY", null, false, true, null, List.of( + new PropertyInfo(null, "TASK", null, false, false, null, null)))), + "dataTable/v1/updateRecord", List.of(TABLE_PROPERTY)); + + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + WorkflowValidator.validateWorkflow( + workflow, (taskType, kind) -> taskDefinitionMap.get(taskType), (taskType, kind, w) -> null, + taskType -> null, + (resourceType, reference) -> "DATA_TABLE".equals(resourceType) ? "Data table '" + reference + + "' does not exist in this environment" : null, + new HashMap<>(), new HashMap<>(), Map.of(), new HashMap<>(), errors, warnings); + + String errorsString = errors.toString(); + + assertEquals( + "[dataTable_1] Resource referenced by property 'table' is not available: Data table 'conversations' " + + "does not exist in this environment", + errorsString); + + int occurrences = errorsString.split("Resource referenced by property 'table'", -1).length - 1; + + assertEquals(1, occurrences); + assertFalse(errorsString.contains("[condition_1]")); + assertEquals("", warnings.toString()); + } +} diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorClusterElementRequiredPropertyTest.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorClusterElementRequiredPropertyTest.java new file mode 100644 index 00000000000..c4f39d7af54 --- /dev/null +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorClusterElementRequiredPropertyTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.workflow.validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.bytechef.platform.workflow.validator.model.PropertyInfo; +import com.bytechef.test.extension.ObjectMapperSetupExtension; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * @author Ivica Cardic + */ +@ExtendWith(ObjectMapperSetupExtension.class) +class WorkflowValidatorClusterElementRequiredPropertyTest { + + private static final Map> TASK_DEFINITION_MAP = Map.of( + "aiAgent/v1/chat", List.of( + new PropertyInfo("userPrompt", "STRING", null, false, true, null, null)), + "googleMail/v1/getEmail", List.of( + new PropertyInfo("id", "STRING", null, true, true, null, null)), + "anthropic/v1/model", List.of( + new PropertyInfo("model", "STRING", null, true, true, null, null), + new PropertyInfo("maxTokens", "INTEGER", null, true, true, null, null))); + + @Test + void validateWorkflowReportsAMissingRequiredPropertyOfATool() { + String workflow = """ + { + "label": "Agent", + "description": "", + "inputs": [], + "triggers": [], + "tasks": [ + { + "label": "AI Agent", + "name": "aiAgent_1", + "type": "aiAgent/v1/chat", + "parameters": { + "userPrompt": "hello" + }, + "clusterElements": { + "tools": [ + { + "label": "Gmail", + "name": "googleMail_1", + "type": "googleMail/v1/getEmail", + "parameters": {} + } + ] + } + } + ] + } + """; + + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + WorkflowValidator.TaskDefinitionProvider taskDefinitionProvider = + (taskType, kind) -> TASK_DEFINITION_MAP.get(taskType); + WorkflowValidator.TaskOutputProvider taskOutputProvider = (taskType, kind, warningsBuilder) -> null; + WorkflowValidator.ClusterTypesProvider clusterTypesProvider = + taskType -> "aiAgent/v1/chat".equals(taskType) ? List.of("tools") : null; + + WorkflowValidator.validateWorkflow( + workflow, taskDefinitionProvider, taskOutputProvider, clusterTypesProvider, new HashMap<>(), + new HashMap<>(), new HashMap<>(), errors, warnings); + + assertEquals("[aiAgent_1] Missing required property: googleMail_1.id", errors.toString()); + } + + @Test + void validateWorkflowReportsAMissingRequiredPropertyOfAModel() { + String workflow = """ + { + "label": "Agent", + "description": "", + "inputs": [], + "triggers": [], + "tasks": [ + { + "label": "AI Agent", + "name": "aiAgent_1", + "type": "aiAgent/v1/chat", + "parameters": { + "userPrompt": "hello" + }, + "clusterElements": { + "model": { + "label": "Anthropic", + "name": "anthropic_1", + "type": "anthropic/v1/model", + "parameters": { + "model": "claude-sonnet-4-6" + } + } + } + } + ] + } + """; + + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + WorkflowValidator.TaskDefinitionProvider taskDefinitionProvider = + (taskType, kind) -> TASK_DEFINITION_MAP.get(taskType); + WorkflowValidator.TaskOutputProvider taskOutputProvider = (taskType, kind, warningsBuilder) -> null; + WorkflowValidator.ClusterTypesProvider clusterTypesProvider = + taskType -> "aiAgent/v1/chat".equals(taskType) ? List.of("model") : null; + + WorkflowValidator.validateWorkflow( + workflow, taskDefinitionProvider, taskOutputProvider, clusterTypesProvider, new HashMap<>(), + new HashMap<>(), new HashMap<>(), errors, warnings); + + assertEquals("[aiAgent_1] Missing required property: anthropic_1.maxTokens", errors.toString()); + } +} diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorClusterElementsTest.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorClusterElementsTest.java index dc41aac85ca..419315d1599 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorClusterElementsTest.java +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorClusterElementsTest.java @@ -139,7 +139,7 @@ void validateWorkflowTasksClusterElementsMissingRequiredProperty() { errors, warnings); assertEquals( - "[aiAgent_1] Missing required property: aiAgent_1.questionAnswerRag_1.couchbase_1.openAi_3.model", + "[aiAgent_1] Missing required property: questionAnswerRag_1.couchbase_1.openAi_3.model", errors.toString()); assertEquals("", warnings.toString()); } catch (Exception e) { @@ -239,7 +239,7 @@ void validateWorkflowTasksClusterElementsDifferentTypeProperty() { errors, warnings); assertEquals( - "[aiAgent_1] Property 'aiAgent_1.questionAnswerRag_1.couchbase_1.openAi_3.model' has incorrect type. Expected: string, but got: number", + "[aiAgent_1] Property 'questionAnswerRag_1.couchbase_1.openAi_3.model' has incorrect type. Expected: string, but got: number", errors.toString()); assertEquals("", warnings.toString()); } catch (Exception e) { @@ -555,7 +555,6 @@ void validateWorkflowTasksClusterElementsInConditionNoError() { assertEquals("", errors.toString()); assertEquals(""" - [condition_1] Property 'expression' is not defined in task definition [condition_1] Cluster element 'model' is missing from task aiAgent_1 [condition_1] Cluster element 'chatMemory' is missing from task aiAgent_1 [condition_1] Cluster element 'rag' is missing from task aiAgent_1 @@ -798,7 +797,6 @@ void validateWorkflowClusterElementsInCondition() { clusterElementTypesMap.toString()); assertEquals("", errors.toString()); assertEquals(""" - [condition_1] Property 'expression' is not defined in task definition [aiAgent_1] Cluster element 'model' is missing from task aiAgent_1 [aiAgent_1] Cluster element 'chatMemory' is missing from task aiAgent_1 [aiAgent_1] Cluster element 'rag' is missing from task aiAgent_1 diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorDuplicateNodeNamesTest.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorDuplicateNodeNamesTest.java index 56e054da50e..779518c2071 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorDuplicateNodeNamesTest.java +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorDuplicateNodeNamesTest.java @@ -340,6 +340,16 @@ public WorkflowValidationResult validateWorkflowById(String workflowId) { return new WorkflowValidationResult(List.of(), List.of()); } + @Override + public WorkflowValidationResult validateWorkflow(String workflow, long environmentId) { + return new WorkflowValidationResult(List.of(), List.of()); + } + + @Override + public WorkflowValidationResult validateWorkflowById(String workflowId, long environmentId) { + return new WorkflowValidationResult(List.of(), List.of()); + } + @Override public List getDuplicateNodeNames(String workflow) { return WorkflowValidator.getDuplicateNodeNames(workflow); diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorDynamicPropertiesTest.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorDynamicPropertiesTest.java new file mode 100644 index 00000000000..2737a70fb3a --- /dev/null +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorDynamicPropertiesTest.java @@ -0,0 +1,170 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.workflow.validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.bytechef.platform.workflow.validator.model.PropertyInfo; +import com.bytechef.test.extension.ObjectMapperSetupExtension; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * @author Ivica Cardic + */ +@ExtendWith(ObjectMapperSetupExtension.class) +class WorkflowValidatorDynamicPropertiesTest { + + private static final Map> TASK_DEFINITION_MAP = Map.of( + "httpClient/v1/get", List.of( + new PropertyInfo("uri", "STRING", null, false, true, null, null), + new PropertyInfo( + "queryParameters", "OBJECT", null, false, true, null, + List.of(new PropertyInfo("locale", "STRING", null, false, true, null, null))), + new PropertyInfo("responseType", "STRING", null, false, true, null, null), + new PropertyInfo( + "responseContentType", "STRING", null, false, true, "responseType == 'BINARY'", null))); + + @Test + void validateWorkflowDoesNotWarnAboutPropertiesRecordedAsDynamic() { + String workflow = """ + { + "label": "Test Workflow", + "description": "", + "inputs": [], + "triggers": [], + "tasks": [ + { + "label": "Get rate", + "name": "httpClient_1", + "type": "httpClient/v1/get", + "metadata": { + "ui": { + "dynamicPropertyTypes": { + "queryParameters.valuta": "ARRAY" + } + } + }, + "parameters": { + "uri": "https://example.com", + "queryParameters": { + "valuta": ["USD"] + } + } + } + ] + } + """; + + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + validateWorkflow(workflow, errors, warnings); + + assertEquals("", errors.toString()); + assertEquals("", warnings.toString()); + } + + @Test + void validateWorkflowWarnsAboutAPropertyThatWasNotRecordedAsDynamic() { + String workflow = """ + { + "label": "Test Workflow", + "description": "", + "inputs": [], + "triggers": [], + "tasks": [ + { + "label": "Get rate", + "name": "httpClient_1", + "type": "httpClient/v1/get", + "metadata": { + "ui": { + "dynamicPropertyTypes": { + "queryParameters.valuta": "ARRAY" + } + } + }, + "parameters": { + "uri": "https://example.com", + "queryParameters": { + "valuta": ["USD"], + "datum": "2024-01-01" + } + } + } + ] + } + """; + + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + validateWorkflow(workflow, errors, warnings); + + assertEquals( + "[httpClient_1] Property 'queryParameters.datum' is not defined in task definition", + warnings.toString()); + } + + @Test + void validateWorkflowDoesNotWarnAboutADefinedPropertyItsDisplayConditionCurrentlyHides() { + String workflow = """ + { + "label": "Test Workflow", + "description": "", + "inputs": [], + "triggers": [], + "tasks": [ + { + "label": "Get", + "name": "httpClient_2", + "type": "httpClient/v1/get", + "parameters": { + "uri": "https://example.com", + "responseType": "JSON", + "responseContentType": "application/octet-stream" + } + } + ] + } + """; + + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + validateWorkflow(workflow, errors, warnings); + + assertEquals("", errors.toString()); + assertEquals("", warnings.toString()); + } + + private static void validateWorkflow(String workflow, StringBuilder errors, StringBuilder warnings) { + WorkflowValidator.TaskDefinitionProvider taskDefinitionProvider = + (taskType, kind) -> TASK_DEFINITION_MAP.get(taskType); + WorkflowValidator.TaskOutputProvider taskOutputProvider = + (taskType, kind, warningsBuilder) -> null; + WorkflowValidator.ClusterTypesProvider clusterTypesProvider = taskType -> null; + + WorkflowValidator.validateWorkflow( + workflow, taskDefinitionProvider, taskOutputProvider, clusterTypesProvider, new HashMap<>(), + new HashMap<>(), new HashMap<>(), errors, warnings); + } +} diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorEmptyRequiredValueTest.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorEmptyRequiredValueTest.java new file mode 100644 index 00000000000..66adaee8998 --- /dev/null +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorEmptyRequiredValueTest.java @@ -0,0 +1,71 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.workflow.validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.bytechef.platform.workflow.validator.model.PropertyInfo; +import com.bytechef.test.extension.ObjectMapperSetupExtension; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * @author Ivica Cardic + */ +@ExtendWith(ObjectMapperSetupExtension.class) +class WorkflowValidatorEmptyRequiredValueTest { + + private static final List TASK_DEFINITION = List.of( + new PropertyInfo("name", "STRING", null, true, true, null, null)); + + @Test + void validateTaskParametersReportsARequiredPropertyClearedToNull() { + StringBuilder errors = new StringBuilder(); + + TaskValidator.validateTaskParameters( + "accelo_1", "{\"name\": null}", TASK_DEFINITION, errors, new StringBuilder()); + + assertEquals("[accelo_1] Missing required property: name", errors.toString()); + } + + @Test + void validateTaskParametersReportsARequiredPropertyLeftBlank() { + StringBuilder errors = new StringBuilder(); + + TaskValidator.validateTaskParameters( + "accelo_1", "{\"name\": \" \"}", TASK_DEFINITION, errors, new StringBuilder()); + + assertEquals("[accelo_1] Missing required property: name", errors.toString()); + } + + @Test + void validateTaskParametersAcceptsAnOptionalPropertyClearedToNull() { + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + TaskValidator.validateTaskParameters( + "accelo_1", "{\"name\": \"Acme\", \"note\": null}", + List.of( + new PropertyInfo("name", "STRING", null, true, true, null, null), + new PropertyInfo("note", "STRING", null, false, true, null, null)), + errors, warnings); + + assertEquals("", errors.toString()); + assertEquals("", warnings.toString()); + } +} diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorFacadeClusterTypesTest.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorFacadeClusterTypesTest.java new file mode 100644 index 00000000000..0acc8dc0aee --- /dev/null +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorFacadeClusterTypesTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.workflow.validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.bytechef.component.definition.ClusterElementDefinition.ClusterElementType; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * @author Ivica Cardic + */ +class WorkflowValidatorFacadeClusterTypesTest { + + private static final List AGENT_CLUSTER_ELEMENT_TYPES = List.of( + new ClusterElementType("MODEL", "model", "Model", true), + new ClusterElementType("CHAT_MEMORY", "chatMemory", "Memory"), + new ClusterElementType("RAG", "rag", "RAG"), + new ClusterElementType("TOOLS", "tools", "Tools", true, false)); + + @Test + void everyClusterElementTypeKeyIsListed() { + assertEquals( + List.of("model", "chatMemory", "rag", "tools"), + WorkflowValidatorFacadeImpl.toClusterElementTypeKeys(AGENT_CLUSTER_ELEMENT_TYPES, false)); + } + + @Test + void onlyRequiredClusterElementTypeKeysAreListedAsRequired() { + assertEquals( + List.of("model"), WorkflowValidatorFacadeImpl.toClusterElementTypeKeys(AGENT_CLUSTER_ELEMENT_TYPES, true)); + } +} diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorFacadeResourceProviderTest.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorFacadeResourceProviderTest.java new file mode 100644 index 00000000000..92c78cd1a87 --- /dev/null +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorFacadeResourceProviderTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.workflow.validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.bytechef.definition.BaseProperty.ResourceType; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * @author Ivica Cardic + */ +class WorkflowValidatorFacadeResourceProviderTest { + + @Test + void dispatchesToResolverForTheResourceTypeWithEnvironment() { + ResourceReferenceResolver dataTableResolver = new ResourceReferenceResolver() { + + @Override + public ResourceType getResourceType() { + return ResourceType.DATA_TABLE; + } + + @Override + public String findProblem(String reference, long environmentId) { + return reference + "@" + environmentId; + } + }; + + WorkflowValidator.ResourceReferenceProvider provider = + WorkflowValidatorFacadeImpl.createResourceReferenceProvider( + Map.of(ResourceType.DATA_TABLE, dataTableResolver), 2L); + + assertEquals("conversations@2", provider.findProblem("DATA_TABLE", "conversations")); + } + + @Test + void unknownResourceTypeResolvesToNoProblem() { + WorkflowValidator.ResourceReferenceProvider provider = + WorkflowValidatorFacadeImpl.createResourceReferenceProvider(Map.of(), 0L); + + assertNull(provider.findProblem("KNOWLEDGE_BASE", "7")); + } +} diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorInputsTest.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorInputsTest.java index 0f7f6350954..b09cf3e068d 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorInputsTest.java +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorInputsTest.java @@ -724,6 +724,16 @@ public WorkflowValidationResult validateWorkflowById(String workflowId) { return new WorkflowValidationResult(List.of(), List.of()); } + @Override + public WorkflowValidationResult validateWorkflow(String workflow, long environmentId) { + return new WorkflowValidationResult(List.of(), List.of()); + } + + @Override + public WorkflowValidationResult validateWorkflowById(String workflowId, long environmentId) { + return new WorkflowValidationResult(List.of(), List.of()); + } + @Override public List getDuplicateNodeNames(String workflow) { return List.of(); diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorNestedTaskAttributionTest.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorNestedTaskAttributionTest.java new file mode 100644 index 00000000000..8aeb0d77bf7 --- /dev/null +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorNestedTaskAttributionTest.java @@ -0,0 +1,112 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.workflow.validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.bytechef.platform.workflow.validator.model.PropertyInfo; +import com.bytechef.test.extension.ObjectMapperSetupExtension; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * @author Ivica Cardic + */ +@ExtendWith(ObjectMapperSetupExtension.class) +class WorkflowValidatorNestedTaskAttributionTest { + + private static final Map> TASK_DEFINITION_MAP = Map.of( + "condition/v1", List.of( + new PropertyInfo("rawExpression", "BOOLEAN", null, false, true, null, null), + new PropertyInfo("expression", "STRING", null, false, true, null, null), + new PropertyInfo( + "caseTrue", "ARRAY", null, false, false, null, + List.of(new PropertyInfo(null, "TASK", null, false, false, null, null))), + new PropertyInfo( + "caseFalse", "ARRAY", null, false, false, null, + List.of(new PropertyInfo(null, "TASK", null, false, false, null, null)))), + "dataStorage/v1/setValue", List.of( + new PropertyInfo("key", "STRING", null, true, true, null, null), + new PropertyInfo("value", "BOOLEAN", null, true, true, null, null))); + + @Test + void validateWorkflowReportsANestedTaskProblemOnceAgainstTheNestedTask() { + String workflow = """ + { + "label": "Nested", + "description": "", + "inputs": [], + "triggers": [], + "tasks": [ + { + "label": "Outer", + "name": "condition_1", + "type": "condition/v1", + "parameters": { + "rawExpression": true, + "expression": "true", + "caseTrue": [ + { + "label": "Inner", + "name": "condition_2", + "type": "condition/v1", + "parameters": { + "rawExpression": true, + "expression": "true", + "caseTrue": [ + { + "label": "Set", + "name": "dataStorage_1", + "type": "dataStorage/v1/setValue", + "parameters": { + "key": "flag", + "value": "not a boolean" + } + } + ], + "caseFalse": [] + } + } + ], + "caseFalse": [] + } + } + ] + } + """; + + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + WorkflowValidator.TaskDefinitionProvider taskDefinitionProvider = + (taskType, kind) -> TASK_DEFINITION_MAP.get(taskType); + WorkflowValidator.TaskOutputProvider taskOutputProvider = (taskType, kind, warningsBuilder) -> null; + WorkflowValidator.ClusterTypesProvider clusterTypesProvider = taskType -> null; + + WorkflowValidator.validateWorkflow( + workflow, taskDefinitionProvider, taskOutputProvider, clusterTypesProvider, new HashMap<>(), + new HashMap<>(), new HashMap<>(), errors, warnings); + + assertEquals( + "[dataStorage_1] Property 'value' has incorrect type. Expected: boolean, but got: string", + errors.toString()); + assertEquals("", warnings.toString()); + } +} diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorOptionalClusterElementTest.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorOptionalClusterElementTest.java new file mode 100644 index 00000000000..54753e14d51 --- /dev/null +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorOptionalClusterElementTest.java @@ -0,0 +1,133 @@ +/* + * Copyright 2025 ByteChef + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytechef.platform.workflow.validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.bytechef.platform.workflow.validator.model.PropertyInfo; +import com.bytechef.test.extension.ObjectMapperSetupExtension; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * @author Ivica Cardic + */ +@ExtendWith(ObjectMapperSetupExtension.class) +class WorkflowValidatorOptionalClusterElementTest { + + private static final Map> TASK_DEFINITION_MAP = Map.of( + "aiAgent/v1/chat", List.of( + new PropertyInfo("userPrompt", "STRING", null, false, true, null, null)), + "anthropic/v1/model", List.of( + new PropertyInfo("model", "STRING", null, true, true, null, null))); + + private static final WorkflowValidator.ClusterTypesProvider CLUSTER_TYPES_PROVIDER = + new WorkflowValidator.ClusterTypesProvider() { + + @Override + @Nullable + public List getClusterElementTypes(String taskType) { + return "aiAgent/v1/chat".equals(taskType) + ? List.of("model", "chatMemory", "rag", "guardrails", "tools") : null; + } + + @Override + @Nullable + public List getRequiredClusterElementTypes(String taskType) { + return "aiAgent/v1/chat".equals(taskType) ? List.of("model") : null; + } + }; + + @Test + void validateWorkflowReportsOnlyTheRequiredClusterElementAsMissing() { + String workflow = agentWorkflow("{}"); + + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + validate(workflow, errors, warnings); + + assertEquals("", errors.toString()); + assertEquals("[aiAgent_1] Cluster element 'model' is missing from task aiAgent_1", warnings.toString()); + } + + @Test + void validateWorkflowStillReportsAClusterElementTheComponentDoesNotDefine() { + String workflow = agentWorkflow(""" + { + "model": { + "label": "Anthropic", + "name": "anthropic_1", + "type": "anthropic/v1/model", + "parameters": { + "model": "claude-sonnet-4-6" + } + }, + "widgets": { + "label": "Widget", + "name": "widget_1", + "type": "acme/v1/widget", + "parameters": {} + } + } + """); + + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + validate(workflow, errors, warnings); + + assertEquals("", errors.toString()); + assertEquals("[aiAgent_1] Cluster element 'widgets' are not defined in task aiAgent_1", warnings.toString()); + } + + private static void validate(String workflow, StringBuilder errors, StringBuilder warnings) { + WorkflowValidator.TaskDefinitionProvider taskDefinitionProvider = + (taskType, kind) -> TASK_DEFINITION_MAP.get(taskType); + WorkflowValidator.TaskOutputProvider taskOutputProvider = (taskType, kind, warningsBuilder) -> null; + + WorkflowValidator.validateWorkflow( + workflow, taskDefinitionProvider, taskOutputProvider, CLUSTER_TYPES_PROVIDER, new HashMap<>(), + new HashMap<>(), new HashMap<>(), errors, warnings); + } + + private static String agentWorkflow(String clusterElements) { + return """ + { + "label": "Agent", + "description": "", + "inputs": [], + "triggers": [], + "tasks": [ + { + "label": "AI Agent", + "name": "aiAgent_1", + "type": "aiAgent/v1/chat", + "parameters": { + "userPrompt": "hello" + }, + "clusterElements": {CLUSTER_ELEMENTS} + } + ] + } + """.replace("{CLUSTER_ELEMENTS}", clusterElements); + } +} diff --git a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorTest.java b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorTest.java index f5106e20950..12924daea84 100644 --- a/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorTest.java +++ b/server/libs/platform/platform-workflow/platform-workflow-validator/platform-workflow-validator-service/src/test/java/com/bytechef/platform/workflow/validator/WorkflowValidatorTest.java @@ -957,16 +957,16 @@ void validateTaskParametersNullTypeMatching() { """; List taskDefinition = List.of( - new PropertyInfo("string", "STRING", null, true, true, null, null), - new PropertyInfo("integer", "INTEGER", null, true, true, null, null), - new PropertyInfo("boolean", "BOOLEAN", null, true, true, null, null), - new PropertyInfo("number", "NUMBER", null, true, true, null, null), - new PropertyInfo("array", "ARRAY", null, true, true, null, null), - new PropertyInfo("object", "OBJECT", null, true, true, null, null), - new PropertyInfo("null", "NULL", null, true, true, null, null), - new PropertyInfo("date", "DATE", null, true, true, null, null), - new PropertyInfo("time", "TIME", null, true, true, null, null), - new PropertyInfo("date_time", "DATE_TIME", null, true, true, null, null)); + new PropertyInfo("string", "STRING", null, false, true, null, null), + new PropertyInfo("integer", "INTEGER", null, false, true, null, null), + new PropertyInfo("boolean", "BOOLEAN", null, false, true, null, null), + new PropertyInfo("number", "NUMBER", null, false, true, null, null), + new PropertyInfo("array", "ARRAY", null, false, true, null, null), + new PropertyInfo("object", "OBJECT", null, false, true, null, null), + new PropertyInfo("null", "NULL", null, false, true, null, null), + new PropertyInfo("date", "DATE", null, false, true, null, null), + new PropertyInfo("time", "TIME", null, false, true, null, null), + new PropertyInfo("date_time", "DATE_TIME", null, false, true, null, null)); StringBuilder errors = new StringBuilder(); StringBuilder warnings = new StringBuilder(); @@ -1231,7 +1231,7 @@ void validateTaskParametersTypesInArrayAndObjectNoErrors() { TaskValidator.validateTaskParameters("testTask", taskParameters, taskDefinition, errors, warnings); assertEquals("", errors.toString()); - assertEquals("[testTask] Property 'config.key' is not defined in task definition", warnings.toString()); + assertEquals("", warnings.toString()); } @Test @@ -1493,10 +1493,7 @@ void validateTaskParametersDisplayConditionFalseIncludesConditionalPropertyConta TaskValidator.validateTaskParameters("testTask", taskParameters, taskDefinition, errors, warnings); assertEquals("", errors.toString()); - assertEquals(""" - [testTask] Property 'featureConfig' is not defined in task definition - [testTask] Property 'featureConfig.setting1' is not defined in task definition - [testTask] Property 'featureConfig.setting2' is not defined in task definition""", warnings.toString()); + assertEquals("", warnings.toString()); } @Test @@ -1622,10 +1619,7 @@ void validateTaskParametersMultipleDisplayConditionsFiltersByConditionIncorrectC [testTask] Missing required property: advancedConfig.mandatory [testTask] Missing required property: advancedConfig.mandatory.name""", errors.toString()); - assertEquals(""" - [testTask] Property 'basicConfig' is not defined in task definition - [testTask] Property 'basicConfig.name' is not defined in task definition""", - warnings.toString()); + assertEquals("", warnings.toString()); } @Test @@ -2026,10 +2020,7 @@ void validateTaskParametersComplexNestedConditionsDeepWarning() { TaskValidator.validateTaskParameters("testTask", taskParameters, taskDefinition, errors, warnings); assertEquals("", errors.toString()); - assertEquals(""" - [testTask] Property 'config1.config2.config3' is not defined in task definition - [testTask] Property 'config1.config2.config3.finalValue' is not defined in task definition""", - warnings.toString()); + assertEquals("", warnings.toString()); } @Test @@ -2365,10 +2356,7 @@ void validateTaskParametersHttpClientPost() { TaskValidator.validateTaskParameters("testTask", taskParameters, taskDefinition, errors, warnings); assertEquals("", errors.toString()); - assertEquals(""" - [testTask] Property 'headers.Authorization' is not defined in task definition - [testTask] Property 'headers.Content-Type' is not defined in task definition - [testTask] Property 'queryParameters.debug' is not defined in task definition""", warnings.toString()); + assertEquals("", warnings.toString()); } @Test @@ -3511,6 +3499,76 @@ void validateWorkflowTasksFlowLoopWrongParameters() { } } + @Test + void validateWorkflowTasksFlowLoopNestedItemPropertyWrongTypeReportsSingleError() { + String tasksJson = """ + [ + { + "label": "Task 1", + "name": "task1", + "type": "component/v1/trigger1", + "parameters": { + "name": "John" + } + }, + { + "label": "Loop", + "name": "loop_1", + "type": "loop/v1", + "parameters": { + "items": "${task1.elements}", + "loopForever": false, + "iteratee": [ + { + "label": "Task 2", + "name": "task2", + "type": "component/v1/action2", + "parameters": { + "age": "${loop_1.item.propBool}" + } + } + ] + } + } + ] + """; + + Map> taskDefinitionMap = Map.of( + "component/v1/trigger1", List.of( + new PropertyInfo("name", "STRING", null, false, true, null, null)), + "loop/v1", List.of( + new PropertyInfo("items", "ARRAY", null, false, true, null, List.of()), + new PropertyInfo("loopForever", "BOOLEAN", null, false, true, null, null), + new PropertyInfo("iteratee", "ARRAY", null, false, true, null, List.of( + new PropertyInfo(null, "TASK", null, false, true, null, null)))), + "component/v1/action2", List.of( + new PropertyInfo("age", "NUMBER", null, false, true, null, null))); + + Map taskOutputMap = Map.of("component/v1/trigger1", actionArr); + + try { + JsonNode tasksJsonNode = JsonUtils.readTree(tasksJson); + List taskJsonNodes = new ArrayList<>(); + + for (JsonNode taskJsonNode : tasksJsonNode) { + taskJsonNodes.add(taskJsonNode); + } + + StringBuilder errors = new StringBuilder(); + StringBuilder warnings = new StringBuilder(); + + WorkflowValidator.validateWorkflowTasks( + taskJsonNodes, taskDefinitionMap, taskOutputMap, new HashMap<>(), errors, warnings); + + assertEquals( + "[loop_1] Property 'loop_1.item[0].propBool' in output of 'loop/v1' is of type boolean, not number", + errors.toString()); + assertEquals("", warnings.toString()); + } catch (Exception e) { + fail("Should not throw exception: " + e.getMessage()); + } + } + @Test void validateWorkflowTasksFlowConditionNoErrors() { String tasksJson = """