Skip to content

Commit

Permalink
Add full form validation and flow validation + callouts
Browse files Browse the repository at this point in the history
Signed-off-by: Tyler Ohlsen <[email protected]>
  • Loading branch information
ohltyler committed Mar 26, 2024
1 parent 82fef7d commit 153b0e7
Show file tree
Hide file tree
Showing 5 changed files with 110 additions and 60 deletions.
1 change: 1 addition & 0 deletions common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 8 additions & 29 deletions public/pages/workflow_detail/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
114 changes: 98 additions & 16 deletions public/pages/workflow_detail/workspace/resizable_workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -43,6 +52,20 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
const [isFirstSave, setIsFirstSave] = useState<boolean>(props.isNewWorkflow);
const isSaveable = isFirstSave ? true : isDirty;

// Workflow state
const [workflow, setWorkflow] = useState<Workflow | undefined>(
props.workflow
);

// Formik form state
const [formValues, setFormValues] = useState<WorkspaceFormValues>({});
const [formSchema, setFormSchema] = useState<WorkspaceSchema>(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<boolean>(true);
const [flowValidOnSubmit, setFlowValidOnSubmit] = useState<boolean>(true);

// Component details side panel state
const [isDetailsPanelOpen, setisDetailsPanelOpen] = useState<boolean>(true);
const collapseFn = useRef(
Expand Down Expand Up @@ -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) => {
Expand All @@ -90,24 +131,20 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
);
}, [selectedComponent]);

// Formik form state
const [formValues, setFormValues] = useState<WorkspaceFormValues>({});
const [formSchema, setFormSchema] = useState<WorkspaceSchema>(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);
});
const initFormSchema = yup.object(initSchemaObj) as WorkspaceSchema;
setFormValues(initFormValues);
setFormSchema(initFormSchema);
}
}, [props.workflow]);
}, [workflow]);

// Update the form values and validation schema when a node is added
// or removed from the workspace.
Expand Down Expand Up @@ -159,6 +196,27 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
>
{(formikProps) => (
<Form>
{!formValidOnSubmit && (
<EuiCallOut
title="There are empty or invalid fields"
color="danger"
iconType="alert"
style={{ marginBottom: '16px' }}
>
Please address the highlighted fields and try saving again.
</EuiCallOut>
)}
{!flowValidOnSubmit && (
<EuiCallOut
title="The configured flow is invalid"
color="danger"
iconType="alert"
style={{ marginBottom: '16px' }}
>
Please ensure there are no open connections between the
components.
</EuiCallOut>
)}
<EuiPageHeader
style={{ marginBottom: '8px' }}
rightSideItems={[
Expand All @@ -171,12 +229,39 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
disabled={!isSaveable}
// TODO: if props.isNewWorkflow is true, clear the workflow cache if saving is successful.
onClick={() => {
// @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
Expand Down Expand Up @@ -205,7 +290,7 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
paddingSize="s"
>
<Workspace
workflow={props.workflow}
workflow={workflow}
onNodesChange={onNodesChange}
/>
</EuiResizablePanel>
Expand All @@ -228,9 +313,6 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
);
}}
</EuiResizableContainer>
<EuiButton onClick={() => formikProps.handleSubmit()}>
Submit
</EuiButton>
</Form>
)}
</Formik>
Expand Down
15 changes: 2 additions & 13 deletions public/pages/workflow_detail/workspace/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
IComponentData,
ReactFlowComponent,
Workflow,
toWorkspaceFlow,
} from '../../../../common';
import { generateId, initComponentData } from '../../../utils';
import { WorkspaceComponent } from '../workspace_component';
Expand Down Expand Up @@ -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);
}
Expand Down

0 comments on commit 153b0e7

Please sign in to comment.