diff --git a/common/utils.ts b/common/utils.ts index fabdb327..45c7002b 100644 --- a/common/utils.ts +++ b/common/utils.ts @@ -74,6 +74,7 @@ export function toWorkspaceFlow( /** * Validates the UI workflow state. * Note we don't have to validate connections since that is done via input/output handlers. + * But we need to validate there are no open connections */ export function validateWorkspaceFlow( workspaceFlow: WorkspaceFlowState diff --git a/public/pages/workflow_detail/component_details/input_fields/select_field.tsx b/public/pages/workflow_detail/component_details/input_fields/select_field.tsx index b95d515e..f76567a8 100644 --- a/public/pages/workflow_detail/component_details/input_fields/select_field.tsx +++ b/public/pages/workflow_detail/component_details/input_fields/select_field.tsx @@ -57,10 +57,9 @@ export function SelectField(props: SelectFieldProps) { options={options} valueOfSelected={field.value || getInitialValue(props.field.type)} onChange={(option) => { - field.onChange(option); form.setFieldValue(formField, option); + props.onFormChange(); }} - onBlur={() => props.onFormChange()} isInvalid={isFieldInvalid( props.componentId, props.field.name, diff --git a/public/pages/workflow_detail/utils/utils.ts b/public/pages/workflow_detail/utils/utils.ts index 85f5ce07..b3037a1c 100644 --- a/public/pages/workflow_detail/utils/utils.ts +++ b/public/pages/workflow_detail/utils/utils.ts @@ -3,41 +3,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - WorkspaceFlowState, - Workflow, - ReactFlowComponent, - toTemplateFlows, - validateWorkspaceFlow, -} from '../../../../common'; +import { Workflow, ReactFlowComponent } from '../../../../common'; -export function saveWorkflow(rfInstance: any, workflow?: Workflow): void { - let curFlowState = rfInstance.toObject(); - - curFlowState = { - ...curFlowState, - nodes: processNodes(curFlowState.nodes), - }; - - const isValid = validateWorkspaceFlow(curFlowState); - if (isValid) { - const updatedWorkflow = { - ...workflow, - workspaceFlowState: curFlowState, - workflows: toTemplateFlows(curFlowState), - } as Workflow; - if (workflow && workflow.id) { - // TODO: implement connection to update workflow API - } else { - // TODO: implement connection to create workflow API - } +export function saveWorkflow(workflow?: Workflow): void { + if (workflow && workflow.id) { + // TODO: implement connection to update workflow API } else { - return; + // TODO: implement connection to create workflow API } } // Process the raw ReactFlow nodes to only persist the fields we need -function processNodes(nodes: ReactFlowComponent[]): ReactFlowComponent[] { +export function processNodes( + nodes: ReactFlowComponent[] +): ReactFlowComponent[] { return nodes .map((node: ReactFlowComponent) => { return Object.fromEntries( diff --git a/public/pages/workflow_detail/workspace/resizable_workspace.tsx b/public/pages/workflow_detail/workspace/resizable_workspace.tsx index 8aadf81b..43bc5d5b 100644 --- a/public/pages/workflow_detail/workspace/resizable_workspace.tsx +++ b/public/pages/workflow_detail/workspace/resizable_workspace.tsx @@ -6,10 +6,15 @@ import React, { useRef, useState, useEffect, useContext } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useOnSelectionChange } from 'reactflow'; -import { Form, Formik, useFormikContext } from 'formik'; +import { Form, Formik } from 'formik'; import * as yup from 'yup'; import { cloneDeep } from 'lodash'; -import { EuiButton, EuiPageHeader, EuiResizableContainer } from '@elastic/eui'; +import { + EuiButton, + EuiCallOut, + EuiPageHeader, + EuiResizableContainer, +} from '@elastic/eui'; import { Workflow, WorkspaceFormValues, @@ -18,11 +23,15 @@ import { WorkspaceSchemaObj, componentDataToFormik, getComponentSchema, + toWorkspaceFlow, + validateWorkspaceFlow, + WorkspaceFlowState, + toTemplateFlows, } from '../../../../common'; import { AppState, removeDirty, setDirty, rfContext } from '../../../store'; import { Workspace } from './workspace'; import { ComponentDetails } from '../component_details'; -import { saveWorkflow } from '../utils'; +import { processNodes, saveWorkflow } from '../utils'; interface ResizableWorkspaceProps { isNewWorkflow: boolean; @@ -43,6 +52,20 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { const [isFirstSave, setIsFirstSave] = useState(props.isNewWorkflow); const isSaveable = isFirstSave ? true : isDirty; + // Workflow state + const [workflow, setWorkflow] = useState( + props.workflow + ); + + // Formik form state + const [formValues, setFormValues] = useState({}); + const [formSchema, setFormSchema] = useState(yup.object({})); + + // Validation states. Maintain separate state for form vs. overall flow so + // we can have fine-grained errors and action items for users + const [formValidOnSubmit, setFormValidOnSubmit] = useState(true); + const [flowValidOnSubmit, setFlowValidOnSubmit] = useState(true); + // Component details side panel state const [isDetailsPanelOpen, setisDetailsPanelOpen] = useState(true); const collapseFn = useRef( @@ -78,6 +101,24 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { }, }); + // Hook to update the workflow's flow state, if applicable. It may not exist if + // it is a backend-only-created workflow, or a new, unsaved workflow + useEffect(() => { + const workflowCopy = { ...props.workflow } as Workflow; + if (workflowCopy) { + if (!workflowCopy.workspaceFlowState) { + workflowCopy.workspaceFlowState = toWorkspaceFlow( + workflowCopy.workflows + ); + console.debug( + `There is no saved UI flow for workflow: ${workflowCopy.name}. Generating a default one.` + ); + } + setWorkflow(workflowCopy); + } + }, [props.workflow]); + + // Hook to updated the selected ReactFlow component useEffect(() => { reactFlowInstance?.setNodes((nodes: ReactFlowComponent[]) => nodes.map((node) => { @@ -90,16 +131,12 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { ); }, [selectedComponent]); - // Formik form state - const [formValues, setFormValues] = useState({}); - const [formSchema, setFormSchema] = useState(yup.object({})); - // Initialize the form state to an existing workflow, if applicable. useEffect(() => { - if (props.workflow?.workspaceFlowState) { + if (workflow?.workspaceFlowState) { const initFormValues = {} as WorkspaceFormValues; const initSchemaObj = {} as WorkspaceSchemaObj; - props.workflow.workspaceFlowState.nodes.forEach((node) => { + workflow.workspaceFlowState.nodes.forEach((node) => { initFormValues[node.id] = componentDataToFormik(node.data); initSchemaObj[node.id] = getComponentSchema(node.data); }); @@ -107,7 +144,7 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { setFormValues(initFormValues); setFormSchema(initFormSchema); } - }, [props.workflow]); + }, [workflow]); // Update the form values and validation schema when a node is added // or removed from the workspace. @@ -159,6 +196,27 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { > {(formikProps) => (
+ {!formValidOnSubmit && ( + + Please address the highlighted fields and try saving again. + + )} + {!flowValidOnSubmit && ( + + Please ensure there are no open connections between the + components. + + )} { - // @ts-ignore - saveWorkflow(reactFlowInstance, props.workflow); dispatch(removeDirty()); if (isFirstSave) { setIsFirstSave(false); } + // Submit the form to bubble up any errors. + // Ideally we handle Promise accept/rejects with submitForm(), but there is + // open issues for that - see https://github.com/jaredpalmer/formik/issues/2057 + // The workaround is to additionally execute validateForm() which will return any errors found. + formikProps.submitForm(); + formikProps.validateForm().then((validationResults: {}) => { + if (Object.keys(validationResults).length > 0) { + setFormValidOnSubmit(false); + } else { + setFormValidOnSubmit(true); + // @ts-ignore + let curFlowState = reactFlowInstance.toObject() as WorkspaceFlowState; + curFlowState = { + ...curFlowState, + nodes: processNodes(curFlowState.nodes), + }; + if (validateWorkspaceFlow(curFlowState)) { + setFlowValidOnSubmit(true); + const updatedWorkflow = { + ...workflow, + workspaceFlowState: curFlowState, + workflows: toTemplateFlows(curFlowState), + } as Workflow; + saveWorkflow(updatedWorkflow); + } else { + setFlowValidOnSubmit(false); + } + } + }); }} > Save @@ -205,7 +290,7 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { paddingSize="s" > @@ -228,9 +313,6 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { ); }} - formikProps.handleSubmit()}> - Submit - )} diff --git a/public/pages/workflow_detail/workspace/workspace.tsx b/public/pages/workflow_detail/workspace/workspace.tsx index 6fb9f5b4..a8ee3c8d 100644 --- a/public/pages/workflow_detail/workspace/workspace.tsx +++ b/public/pages/workflow_detail/workspace/workspace.tsx @@ -21,7 +21,6 @@ import { IComponentData, ReactFlowComponent, Workflow, - toWorkspaceFlow, } from '../../../../common'; import { generateId, initComponentData } from '../../../utils'; import { WorkspaceComponent } from '../workspace_component'; @@ -116,20 +115,10 @@ export function Workspace(props: WorkspaceProps) { [reactFlowInstance] ); - // Initialization. Set the nodes and edges to an existing workflow, - // if applicable. + // Initialization. Set the nodes and edges to an existing workflow state, useEffect(() => { const workflow = { ...props.workflow }; - if (workflow) { - if (!workflow.workspaceFlowState) { - // No existing workspace state. This could be due to it being a backend-only-created - // workflow, or a new, unsaved workflow - // @ts-ignore - workflow.workspaceFlowState = toWorkspaceFlow(workflow.workflows); - console.debug( - `There is no saved UI flow for workflow: ${workflow.name}. Generating a default one.` - ); - } + if (workflow && workflow.workspaceFlowState) { setNodes(workflow.workspaceFlowState.nodes); setEdges(workflow.workspaceFlowState.edges); }