diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/selectors.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/selectors.ts index 9f4ba7f78c4e7..47c00dc7e93b3 100644 --- a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/selectors.ts +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/selectors.ts @@ -51,11 +51,17 @@ export const selectWorkflowDefinition = createSelector( (computed) => computed?.workflowDefinition ); -// Only checks if the current workflow yaml can be parses, does check the schema, only the yaml syntax +// Only checks if the current workflow yaml can be parsed, does not check the schema, only the yaml syntax export const selectIsYamlSyntaxValid = createSelector(selectYamlComputed, (computed): boolean => Boolean(computed?.workflowDefinition) ); +// Checks whether validation errors (from strict schema + custom validations) are present +export const selectHasYamlSchemaValidationErrors = createSelector( + selectDetail, + (detail): boolean => detail.hasYamlSchemaValidationErrors +); + export const selectFocusedStepId = createSelector(selectDetail, (detail) => detail.focusedStepId); export const selectHighlightedStepId = createSelector( diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/slice.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/slice.ts index 404a98f6e877d..88aad8972d9e8 100644 --- a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/slice.ts +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/slice.ts @@ -31,6 +31,7 @@ const initialState: WorkflowDetailState = { highlightedStepId: undefined, isTestModalOpen: false, loading: initialLoadingState, + hasYamlSchemaValidationErrors: false, connectorFlyout: { isOpen: false, connectorType: undefined, @@ -89,6 +90,10 @@ const workflowDetailSlice = createSlice({ state.activeTab = action.payload; }, + setHasYamlSchemaValidationErrors: (state, action: { payload: boolean }) => { + state.hasYamlSchemaValidationErrors = action.payload; + }, + // Connector flyout actions openCreateConnectorFlyout: ( state, @@ -151,6 +156,7 @@ export const { setExecution, clearExecution, setActiveTab, + setHasYamlSchemaValidationErrors, openCreateConnectorFlyout, openEditConnectorFlyout, closeConnectorFlyout, diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/types.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/types.ts index cf3fd51b051b9..7312ad9c68666 100644 --- a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/types.ts +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/types.ts @@ -49,6 +49,8 @@ export interface WorkflowDetailState { schema: WorkflowZodSchemaType; /** Loading states for async operations */ loading: LoadingStates; + /** Whether the editor has validation errors (strict schema + custom validations) */ + hasYamlSchemaValidationErrors: boolean; /** Connector flyout state */ connectorFlyout: { isOpen: boolean; diff --git a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.test.tsx b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.test.tsx index 865421b7eea96..dc019721d3619 100644 --- a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.test.tsx +++ b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.test.tsx @@ -13,7 +13,8 @@ import type { WorkflowDetailHeaderProps } from './workflow_detail_header'; import { WorkflowDetailHeader } from './workflow_detail_header'; import { createMockStore } from '../../../entities/workflows/store/__mocks__/store.mock'; import { - _setComputedDataInternal, + _clearComputedData, + setHasYamlSchemaValidationErrors, setWorkflow, setYamlString, } from '../../../entities/workflows/store/workflow_detail/slice'; @@ -72,27 +73,33 @@ describe('WorkflowDetailHeader', () => { const renderWithProviders = ( component: React.ReactElement, - { isValid = true, hasChanges = false }: { isValid?: boolean; hasChanges?: boolean } = {} + { + isValid = true, + hasChanges = false, + hasYamlSchemaValidationErrors = false, + serverValid = true, + }: { + isValid?: boolean; + hasChanges?: boolean; + hasYamlSchemaValidationErrors?: boolean; + serverValid?: boolean; + } = {} ) => { const store = createMockStore(); - // Set up the workflow in the store - store.dispatch(setWorkflow(mockWorkflow)); + // Set up the workflow in the store (with server-side valid flag) + store.dispatch(setWorkflow({ ...mockWorkflow, valid: serverValid })); store.dispatch(setYamlString(hasChanges ? 'modified yaml' : mockWorkflow.yaml)); - // Set computed data to control syntax validation - if (isValid) { - store.dispatch( - _setComputedDataInternal({ - workflowDefinition: { - version: '1', - name: 'Test Workflow', - enabled: true, - triggers: [], - steps: [], - }, - }) - ); + if (!isValid) { + // Clear the computed data that the middleware auto-generated from the yaml, + // so that selectIsYamlSyntaxValid returns false + store.dispatch(_clearComputedData()); + } + + // Simulate strict validation errors from Monaco + if (hasYamlSchemaValidationErrors) { + store.dispatch(setHasYamlSchemaValidationErrors(true)); } const wrapper = ({ children }: { children: React.ReactNode }) => { @@ -158,15 +165,39 @@ describe('WorkflowDetailHeader', () => { expect(container).toBeTruthy(); }); - // We shouldn't rely on parseResult to determine if the yaml is valid now - // instead we should move validationErrors to the store and use it to determine it - it.skip('disables run workflow button when yaml is invalid', () => { + it('disables run workflow button when yaml has syntax errors', () => { const result = renderWithProviders(, { isValid: false, }); expect(result.getByTestId('runWorkflowHeaderButton')).toBeDisabled(); }); + it('disables run workflow button when yaml has validation errors', () => { + const result = renderWithProviders(, { + isValid: true, + hasYamlSchemaValidationErrors: true, + }); + expect(result.getByTestId('runWorkflowHeaderButton')).toBeDisabled(); + }); + + it('disables enabled toggle when yaml has validation errors', () => { + const result = renderWithProviders(, { + isValid: true, + hasYamlSchemaValidationErrors: true, + }); + const toggle = result.getByRole('switch'); + expect(toggle).toBeDisabled(); + }); + + it('disables enabled toggle when server reports workflow as invalid (e.g. initial page load)', () => { + const result = renderWithProviders(, { + isValid: true, + serverValid: false, + }); + const toggle = result.getByRole('switch'); + expect(toggle).toBeDisabled(); + }); + it('enables run workflow button when yaml is valid', () => { const result = renderWithProviders(); const button = result.getByTestId('runWorkflowHeaderButton'); diff --git a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.tsx b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.tsx index e4cf63209909b..afb35966b37e2 100644 --- a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.tsx +++ b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.tsx @@ -37,6 +37,7 @@ import { useSaveYaml } from '../../../entities/workflows/model/use_save_yaml'; import { useUpdateWorkflow } from '../../../entities/workflows/model/use_update_workflow'; import { selectHasChanges, + selectHasYamlSchemaValidationErrors, selectIsExecutionsTab, selectIsSavingYaml, selectIsYamlSynced, @@ -105,6 +106,7 @@ export const WorkflowDetailHeader = React.memo( const workflow = useSelector(selectWorkflow); const isSyntaxValid = useSelector(selectIsYamlSyntaxValid); + const hasYamlSchemaValidationErrors = useSelector(selectHasYamlSchemaValidationErrors); const hasUnsavedChanges = useSelector(selectHasChanges); const isExecutionsTab = useSelector(selectIsExecutionsTab); const isYamlSynced = useSelector(selectIsYamlSynced); @@ -135,13 +137,17 @@ export const WorkflowDetailHeader = React.memo( const [showRunConfirmation, setShowRunConfirmation] = useState(false); + // Combined validity: syntax must parse AND no strict validation errors AND server considers it valid. + // workflow?.valid !== false covers the initial page load before Monaco validates. + const isValid = isSyntaxValid && !hasYamlSchemaValidationErrors && workflow?.valid !== false; + const runWorkflowTooltipContent = useMemo(() => { return getTestRunTooltipContent({ isExecutionsTab, - isValid: isSyntaxValid, + isValid, canRunWorkflow: canExecuteWorkflow, }); - }, [isSyntaxValid, canExecuteWorkflow, isExecutionsTab]); + }, [isValid, canExecuteWorkflow, isExecutionsTab]); const saveWorkflowTooltipContent = useMemo(() => { const isCreate = !workflowId; @@ -262,9 +268,9 @@ export const WorkflowDetailHeader = React.memo( ? i18n.translate('workflows.workflowDetailHeader.unsaved', { defaultMessage: 'Save changes to enable/disable workflow', }) - : !isSyntaxValid + : !isValid ? i18n.translate('workflows.workflowDetailHeader.invalid', { - defaultMessage: 'Fix errors to enable workflow', + defaultMessage: 'Fix validation errors to enable workflow', }) : undefined } @@ -274,7 +280,7 @@ export const WorkflowDetailHeader = React.memo( !workflowId || isLoading || !canUpdateWorkflow || - !isSyntaxValid || + !isValid || hasUnsavedChanges } checked={isEnabled} @@ -292,7 +298,7 @@ export const WorkflowDetailHeader = React.memo( iconType="play" size="s" onClick={handleRunClickWithUnsavedCheck} - disabled={isExecutionsTab || !canExecuteWorkflow || isLoading || !isSyntaxValid} + disabled={isExecutionsTab || !canExecuteWorkflow || isLoading || !isValid} aria-label={Translations.runWorkflow} data-test-subj="runWorkflowHeaderButton" /> diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx index 8aba9baeff69a..b63787a3646e9 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx @@ -53,7 +53,10 @@ import { selectStepExecutions, selectWorkflow, } from '../../../entities/workflows/store/workflow_detail/selectors'; -import { setIsTestModalOpen } from '../../../entities/workflows/store/workflow_detail/slice'; +import { + setHasYamlSchemaValidationErrors, + setIsTestModalOpen, +} from '../../../entities/workflows/store/workflow_detail/slice'; import { ActionsMenuPopover } from '../../../features/actions_menu_popover'; import type { ActionOptionData } from '../../../features/actions_menu_popover/types'; import { useMonacoMarkersChangedInterceptor } from '../../../features/validate_workflow_yaml/lib/use_monaco_markers_changed_interceptor'; @@ -256,6 +259,12 @@ export const WorkflowYAMLEditor = ({ workflowYamlSchema: workflowYamlSchema as z.ZodSchema, }); + // Sync validation error state to Redux so sibling components (e.g. header toggle) can react + useEffect(() => { + const hasErrors = validationErrors.some((e) => e.severity === 'error'); + dispatch(setHasYamlSchemaValidationErrors(hasErrors)); + }, [validationErrors, dispatch]); + const handleErrorClick = useCallback((error: YamlValidationResult) => { if (!editorRef.current) { return;