Skip to content

Commit 75ca998

Browse files
opensearch-trigger-bot[bot]ohltyler
andauthoredMar 27, 2024··
Enforce fine-grained form and flow validation (#114) (#117)
Signed-off-by: Tyler Ohlsen <ohltyler@amazon.com> (cherry picked from commit 5231f09) Co-authored-by: Tyler Ohlsen <ohltyler@amazon.com>

File tree

13 files changed

+195
-99
lines changed

13 files changed

+195
-99
lines changed
 

‎common/utils.ts

+1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export function toWorkspaceFlow(
7676
/**
7777
* Validates the UI workflow state.
7878
* Note we don't have to validate connections since that is done via input/output handlers.
79+
* But we need to validate there are no open connections
7980
*/
8081
export function validateWorkspaceFlow(
8182
workspaceFlow: WorkspaceFlowState

‎public/pages/workflow_detail/component_details/component_details.tsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { EmptyComponentInputs } from './empty_component_inputs';
1313
import '../workspace/workspace-styles.scss';
1414

1515
interface ComponentDetailsProps {
16+
onFormChange: () => void;
1617
selectedComponent?: ReactFlowComponent;
1718
}
1819

@@ -31,7 +32,10 @@ export function ComponentDetails(props: ComponentDetailsProps) {
3132
<EuiFlexItem>
3233
<EuiPanel paddingSize="m">
3334
{props.selectedComponent ? (
34-
<ComponentInputs selectedComponent={props.selectedComponent} />
35+
<ComponentInputs
36+
selectedComponent={props.selectedComponent}
37+
onFormChange={props.onFormChange}
38+
/>
3539
) : (
3640
<EmptyComponentInputs />
3741
)}

‎public/pages/workflow_detail/component_details/component_inputs.tsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ReactFlowComponent } from '../../../../common';
1010

1111
interface ComponentInputsProps {
1212
selectedComponent: ReactFlowComponent;
13+
onFormChange: () => void;
1314
}
1415

1516
export function ComponentInputs(props: ComponentInputsProps) {
@@ -19,7 +20,10 @@ export function ComponentInputs(props: ComponentInputsProps) {
1920
<h2>{props.selectedComponent.data.label || ''}</h2>
2021
</EuiTitle>
2122
<EuiSpacer size="s" />
22-
<InputFieldList selectedComponent={props.selectedComponent} />
23+
<InputFieldList
24+
selectedComponent={props.selectedComponent}
25+
onFormChange={props.onFormChange}
26+
/>
2327
</>
2428
);
2529
}

‎public/pages/workflow_detail/component_details/input_field_list.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ReactFlowComponent } from '../../../../common';
1515

1616
interface InputFieldListProps {
1717
selectedComponent: ReactFlowComponent;
18+
onFormChange: () => void;
1819
}
1920

2021
export function InputFieldList(props: InputFieldListProps) {
@@ -30,6 +31,7 @@ export function InputFieldList(props: InputFieldListProps) {
3031
<TextField
3132
field={field}
3233
componentId={props.selectedComponent.id}
34+
onFormChange={props.onFormChange}
3335
/>
3436
<EuiSpacer size="s" />
3537
</EuiFlexItem>
@@ -42,6 +44,7 @@ export function InputFieldList(props: InputFieldListProps) {
4244
<SelectField
4345
field={field}
4446
componentId={props.selectedComponent.id}
47+
onFormChange={props.onFormChange}
4548
/>
4649
</EuiFlexItem>
4750
);

‎public/pages/workflow_detail/component_details/input_fields/select_field.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const existingIndices = [
3636
interface SelectFieldProps {
3737
field: IComponentField;
3838
componentId: string;
39+
onFormChange: () => void;
3940
}
4041

4142
/**
@@ -56,8 +57,8 @@ export function SelectField(props: SelectFieldProps) {
5657
options={options}
5758
valueOfSelected={field.value || getInitialValue(props.field.type)}
5859
onChange={(option) => {
59-
field.onChange(option);
6060
form.setFieldValue(formField, option);
61+
props.onFormChange();
6162
}}
6263
isInvalid={isFieldInvalid(
6364
props.componentId,

‎public/pages/workflow_detail/component_details/input_fields/text_field.tsx

+7
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
interface TextFieldProps {
1818
field: IComponentField;
1919
componentId: string;
20+
onFormChange: () => void;
2021
}
2122

2223
/**
@@ -46,6 +47,12 @@ export function TextField(props: TextFieldProps) {
4647
placeholder={props.field.placeholder || ''}
4748
compressed={false}
4849
value={field.value || getInitialValue(props.field.type)}
50+
onChange={(e) => form.setFieldValue(formField, e.target.value)}
51+
// This is a design decision to only trigger form updates onBlur() instead
52+
// of onChange(). This is to rate limit the number of updates & re-renders made, as users
53+
// typically rapidly type things into a text box, which would consequently trigger
54+
// onChange() much more often.
55+
onBlur={() => props.onFormChange()}
4956
/>
5057
</EuiFormRow>
5158
);

‎public/pages/workflow_detail/components/header.tsx

+3-15
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,9 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) {
3333
)
3434
}
3535
rightSideItems={[
36-
// TODO: add launch logic
37-
<EuiButton fill={false} onClick={() => {}}>
38-
Launch
39-
</EuiButton>,
40-
<EuiButton
41-
fill={false}
42-
disabled={!props.workflow || !isDirty}
43-
// TODO: if isNewWorkflow is true, clear the workflow cache if saving is successful.
44-
onClick={() => {
45-
// @ts-ignore
46-
saveWorkflow(props.workflow, reactFlowInstance);
47-
dispatch(removeDirty());
48-
}}
49-
>
50-
Save
36+
// TODO: finalize if this is needed
37+
<EuiButton fill={false} color="danger" onClick={() => {}}>
38+
Delete
5139
</EuiButton>,
5240
]}
5341
tabs={props.tabs}

‎public/pages/workflow_detail/utils/utils.ts

+8-29
Original file line numberDiff line numberDiff line change
@@ -3,41 +3,20 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import {
7-
WorkspaceFlowState,
8-
Workflow,
9-
ReactFlowComponent,
10-
toTemplateFlows,
11-
validateWorkspaceFlow,
12-
} from '../../../../common';
6+
import { Workflow, ReactFlowComponent } from '../../../../common';
137

14-
export function saveWorkflow(workflow: Workflow, rfInstance: any): void {
15-
let curFlowState = rfInstance.toObject();
16-
17-
curFlowState = {
18-
...curFlowState,
19-
nodes: processNodes(curFlowState.nodes),
20-
};
21-
22-
const isValid = validateWorkspaceFlow(curFlowState);
23-
if (isValid) {
24-
const updatedWorkflow = {
25-
...workflow,
26-
workspaceFlowState: curFlowState,
27-
workflows: toTemplateFlows(curFlowState),
28-
} as Workflow;
29-
if (workflow.id) {
30-
// TODO: implement connection to update workflow API
31-
} else {
32-
// TODO: implement connection to create workflow API
33-
}
8+
export function saveWorkflow(workflow?: Workflow): void {
9+
if (workflow && workflow.id) {
10+
// TODO: implement connection to update workflow API
3411
} else {
35-
return;
12+
// TODO: implement connection to create workflow API
3613
}
3714
}
3815

3916
// Process the raw ReactFlow nodes to only persist the fields we need
40-
function processNodes(nodes: ReactFlowComponent[]): ReactFlowComponent[] {
17+
export function processNodes(
18+
nodes: ReactFlowComponent[]
19+
): ReactFlowComponent[] {
4120
return nodes
4221
.map((node: ReactFlowComponent) => {
4322
return Object.fromEntries(

‎public/pages/workflow_detail/workflow_detail.tsx

+4-12
Original file line numberDiff line numberDiff line change
@@ -98,22 +98,11 @@ export function WorkflowDetail(props: WorkflowDetailProps) {
9898

9999
// On initial load:
100100
// - fetch workflow, if there is an existing workflow ID
101-
// - add a window listener to warn users if they exit/refresh
102-
// without saving latest changes
103101
useEffect(() => {
104102
if (!isNewWorkflow) {
105103
// TODO: can optimize to only fetch a single workflow
106104
dispatch(searchWorkflows({ query: { match_all: {} } }));
107105
}
108-
109-
// TODO: below has the following issue:
110-
// 1. user starts to create new unsaved workflow changes
111-
// 2. user navigates to other parts of the plugin without refreshing - no warning happens
112-
// 3. user refreshes at any later time: if isDirty is still true, shows browser warning
113-
// tune to only handle the check if still on the workflow details page, or consider adding a check / warning
114-
// if navigating away from the details page without refreshing (where it is currently not being triggered)
115-
// window.onbeforeunload = (e) =>
116-
// isDirty || isNewWorkflow ? true : undefined;
117106
}, []);
118107

119108
const tabs = [
@@ -156,7 +145,10 @@ export function WorkflowDetail(props: WorkflowDetailProps) {
156145
tabs={tabs}
157146
/>
158147
{selectedTabId === WORKFLOW_DETAILS_TAB.EDITOR && (
159-
<ResizableWorkspace workflow={workflow} />
148+
<ResizableWorkspace
149+
isNewWorkflow={isNewWorkflow}
150+
workflow={workflow}
151+
/>
160152
)}
161153
{selectedTabId === WORKFLOW_DETAILS_TAB.LAUNCHES && <Launches />}
162154
{selectedTabId === WORKFLOW_DETAILS_TAB.PROTOTYPE && (

‎public/pages/workflow_detail/workspace/resizable_workspace.tsx

+150-16
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@
44
*/
55

66
import React, { useRef, useState, useEffect, useContext } from 'react';
7+
import { useDispatch, useSelector } from 'react-redux';
78
import { useOnSelectionChange } from 'reactflow';
89
import { Form, Formik } from 'formik';
910
import * as yup from 'yup';
1011
import { cloneDeep } from 'lodash';
11-
import { EuiButton, EuiResizableContainer } from '@elastic/eui';
12+
import {
13+
EuiButton,
14+
EuiCallOut,
15+
EuiPageHeader,
16+
EuiResizableContainer,
17+
} from '@elastic/eui';
1218
import {
1319
Workflow,
1420
WorkspaceFormValues,
@@ -17,12 +23,18 @@ import {
1723
WorkspaceSchemaObj,
1824
componentDataToFormik,
1925
getComponentSchema,
26+
toWorkspaceFlow,
27+
validateWorkspaceFlow,
28+
WorkspaceFlowState,
29+
toTemplateFlows,
2030
} from '../../../../common';
21-
import { rfContext } from '../../../store';
31+
import { AppState, removeDirty, setDirty, rfContext } from '../../../store';
2232
import { Workspace } from './workspace';
2333
import { ComponentDetails } from '../component_details';
34+
import { processNodes, saveWorkflow } from '../utils';
2435

2536
interface ResizableWorkspaceProps {
37+
isNewWorkflow: boolean;
2638
workflow?: Workflow;
2739
}
2840

@@ -33,6 +45,27 @@ const COMPONENT_DETAILS_PANEL_ID = 'component_details_panel_id';
3345
* panels - the ReactFlow workspace panel and the selected component details panel.
3446
*/
3547
export function ResizableWorkspace(props: ResizableWorkspaceProps) {
48+
const dispatch = useDispatch();
49+
50+
// Overall workspace state
51+
const isDirty = useSelector((state: AppState) => state.workspace.isDirty);
52+
const [isFirstSave, setIsFirstSave] = useState<boolean>(props.isNewWorkflow);
53+
const isSaveable = isFirstSave ? true : isDirty;
54+
55+
// Workflow state
56+
const [workflow, setWorkflow] = useState<Workflow | undefined>(
57+
props.workflow
58+
);
59+
60+
// Formik form state
61+
const [formValues, setFormValues] = useState<WorkspaceFormValues>({});
62+
const [formSchema, setFormSchema] = useState<WorkspaceSchema>(yup.object({}));
63+
64+
// Validation states. Maintain separate state for form vs. overall flow so
65+
// we can have fine-grained errors and action items for users
66+
const [formValidOnSubmit, setFormValidOnSubmit] = useState<boolean>(true);
67+
const [flowValidOnSubmit, setFlowValidOnSubmit] = useState<boolean>(true);
68+
3669
// Component details side panel state
3770
const [isDetailsPanelOpen, setisDetailsPanelOpen] = useState<boolean>(true);
3871
const collapseFn = useRef(
@@ -68,6 +101,25 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
68101
},
69102
});
70103

104+
// Hook to update the workflow's flow state, if applicable. It may not exist if
105+
// it is a backend-only-created workflow, or a new, unsaved workflow. If so,
106+
// generate a default one based on the 'workflows' JSON field.
107+
useEffect(() => {
108+
const workflowCopy = { ...props.workflow } as Workflow;
109+
if (workflowCopy) {
110+
if (!workflowCopy.workspaceFlowState) {
111+
workflowCopy.workspaceFlowState = toWorkspaceFlow(
112+
workflowCopy.workflows
113+
);
114+
console.debug(
115+
`There is no saved UI flow for workflow: ${workflowCopy.name}. Generating a default one.`
116+
);
117+
}
118+
setWorkflow(workflowCopy);
119+
}
120+
}, [props.workflow]);
121+
122+
// Hook to updated the selected ReactFlow component
71123
useEffect(() => {
72124
reactFlowInstance?.setNodes((nodes: ReactFlowComponent[]) =>
73125
nodes.map((node) => {
@@ -80,24 +132,20 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
80132
);
81133
}, [selectedComponent]);
82134

83-
// Formik form state
84-
const [formValues, setFormValues] = useState<WorkspaceFormValues>({});
85-
const [formSchema, setFormSchema] = useState<WorkspaceSchema>(yup.object({}));
86-
87135
// Initialize the form state to an existing workflow, if applicable.
88136
useEffect(() => {
89-
if (props.workflow?.workspaceFlowState) {
137+
if (workflow?.workspaceFlowState) {
90138
const initFormValues = {} as WorkspaceFormValues;
91139
const initSchemaObj = {} as WorkspaceSchemaObj;
92-
props.workflow.workspaceFlowState.nodes.forEach((node) => {
140+
workflow.workspaceFlowState.nodes.forEach((node) => {
93141
initFormValues[node.id] = componentDataToFormik(node.data);
94142
initSchemaObj[node.id] = getComponentSchema(node.data);
95143
});
96144
const initFormSchema = yup.object(initSchemaObj) as WorkspaceSchema;
97145
setFormValues(initFormValues);
98146
setFormSchema(initFormSchema);
99147
}
100-
}, [props.workflow]);
148+
}, [workflow]);
101149

102150
// Update the form values and validation schema when a node is added
103151
// or removed from the workspace.
@@ -129,6 +177,16 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
129177
setFormSchema(updatedSchema);
130178
}
131179

180+
/**
181+
* Function to pass down to the Formik <Form> components as a listener to propagate
182+
* form changes to this parent component to re-enable save button, etc.
183+
*/
184+
function onFormChange() {
185+
if (!isDirty) {
186+
dispatch(setDirty());
187+
}
188+
}
189+
132190
return (
133191
<Formik
134192
enableReinitialize={true}
@@ -139,9 +197,84 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
139197
>
140198
{(formikProps) => (
141199
<Form>
200+
{!formValidOnSubmit && (
201+
<EuiCallOut
202+
title="There are empty or invalid fields"
203+
color="danger"
204+
iconType="alert"
205+
style={{ marginBottom: '16px' }}
206+
>
207+
Please address the highlighted fields and try saving again.
208+
</EuiCallOut>
209+
)}
210+
{!flowValidOnSubmit && (
211+
<EuiCallOut
212+
title="The configured flow is invalid"
213+
color="danger"
214+
iconType="alert"
215+
style={{ marginBottom: '16px' }}
216+
>
217+
Please ensure there are no open connections between the
218+
components.
219+
</EuiCallOut>
220+
)}
221+
<EuiPageHeader
222+
style={{ marginBottom: '8px' }}
223+
rightSideItems={[
224+
// TODO: add launch logic
225+
<EuiButton fill={false} onClick={() => {}}>
226+
Launch
227+
</EuiButton>,
228+
<EuiButton
229+
fill={false}
230+
disabled={!isSaveable}
231+
// TODO: if props.isNewWorkflow is true, clear the workflow cache if saving is successful.
232+
onClick={() => {
233+
dispatch(removeDirty());
234+
if (isFirstSave) {
235+
setIsFirstSave(false);
236+
}
237+
// Submit the form to bubble up any errors.
238+
// Ideally we handle Promise accept/rejects with submitForm(), but there is
239+
// open issues for that - see https://github.com/jaredpalmer/formik/issues/2057
240+
// The workaround is to additionally execute validateForm() which will return any errors found.
241+
formikProps.submitForm();
242+
formikProps.validateForm().then((validationResults: {}) => {
243+
if (Object.keys(validationResults).length > 0) {
244+
setFormValidOnSubmit(false);
245+
} else {
246+
setFormValidOnSubmit(true);
247+
// @ts-ignore
248+
let curFlowState = reactFlowInstance.toObject() as WorkspaceFlowState;
249+
curFlowState = {
250+
...curFlowState,
251+
nodes: processNodes(curFlowState.nodes),
252+
};
253+
if (validateWorkspaceFlow(curFlowState)) {
254+
setFlowValidOnSubmit(true);
255+
const updatedWorkflow = {
256+
...workflow,
257+
workspaceFlowState: curFlowState,
258+
workflows: toTemplateFlows(curFlowState),
259+
} as Workflow;
260+
saveWorkflow(updatedWorkflow);
261+
} else {
262+
setFlowValidOnSubmit(false);
263+
}
264+
}
265+
});
266+
}}
267+
>
268+
Save
269+
</EuiButton>,
270+
]}
271+
bottomBorder={false}
272+
/>
142273
<EuiResizableContainer
143274
direction="horizontal"
144-
style={{ marginLeft: '-8px', marginTop: '-8px' }}
275+
style={{
276+
marginLeft: '-8px',
277+
}}
145278
>
146279
{(EuiResizablePanel, EuiResizableButton, { togglePanel }) => {
147280
if (togglePanel) {
@@ -153,33 +286,34 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
153286
<>
154287
<EuiResizablePanel
155288
mode="main"
156-
initialSize={75}
289+
initialSize={80}
157290
minSize="50%"
158291
paddingSize="s"
159292
>
160293
<Workspace
161-
workflow={props.workflow}
294+
workflow={workflow}
162295
onNodesChange={onNodesChange}
163296
/>
164297
</EuiResizablePanel>
165298
<EuiResizableButton />
166299
<EuiResizablePanel
300+
style={{ marginRight: '-16px' }}
167301
id={COMPONENT_DETAILS_PANEL_ID}
168302
mode="collapsible"
169303
initialSize={25}
170304
minSize="10%"
171305
paddingSize="s"
172306
onToggleCollapsedInternal={() => onToggleChange()}
173307
>
174-
<ComponentDetails selectedComponent={selectedComponent} />
308+
<ComponentDetails
309+
selectedComponent={selectedComponent}
310+
onFormChange={onFormChange}
311+
/>
175312
</EuiResizablePanel>
176313
</>
177314
);
178315
}}
179316
</EuiResizableContainer>
180-
<EuiButton onClick={() => formikProps.handleSubmit()}>
181-
Submit
182-
</EuiButton>
183317
</Form>
184318
)}
185319
</Formik>

‎public/pages/workflow_detail/workspace/workspace.tsx

+2-13
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import {
2121
IComponentData,
2222
ReactFlowComponent,
2323
Workflow,
24-
toWorkspaceFlow,
2524
} from '../../../../common';
2625
import { generateId, initComponentData } from '../../../utils';
2726
import { WorkspaceComponent } from '../workspace_component';
@@ -116,20 +115,10 @@ export function Workspace(props: WorkspaceProps) {
116115
[reactFlowInstance]
117116
);
118117

119-
// Initialization. Set the nodes and edges to an existing workflow,
120-
// if applicable.
118+
// Initialization. Set the nodes and edges to an existing workflow state,
121119
useEffect(() => {
122120
const workflow = { ...props.workflow };
123-
if (workflow) {
124-
if (!workflow.workspaceFlowState) {
125-
// No existing workspace state. This could be due to it being a backend-only-created
126-
// workflow, or a new, unsaved workflow
127-
// @ts-ignore
128-
workflow.workspaceFlowState = toWorkspaceFlow(workflow.workflows);
129-
console.debug(
130-
`There is no saved UI flow for workflow: ${workflow.name}. Generating a default one.`
131-
);
132-
}
121+
if (workflow && workflow.workspaceFlowState) {
133122
setNodes(workflow.workspaceFlowState.nodes);
134123
setEdges(workflow.workspaceFlowState.edges);
135124
}

‎public/pages/workflows/workflows.test.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const renderWithRouter = () => ({
4040

4141
describe('Workflows', () => {
4242
test('renders the page', () => {
43-
const { getByText } = renderWithRouter();
44-
expect(getByText('Workflows')).not.toBeNull();
43+
const { getAllByText } = renderWithRouter();
44+
expect(getAllByText('Workflows').length).toBeGreaterThan(0);
4545
});
4646
});

‎public/pages/workflows/workflows.tsx

+3-9
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,14 @@ export function Workflows(props: WorkflowsProps) {
5858
] as WORKFLOWS_TAB;
5959
const [selectedTabId, setSelectedTabId] = useState<WORKFLOWS_TAB>(tabFromUrl);
6060

61-
// If there is no selected tab or invalid tab, default to a tab depending
62-
// on if user has existing created workflows or not.
61+
// If there is no selected tab or invalid tab, default to manage tab
6362
useEffect(() => {
6463
if (
6564
!selectedTabId ||
6665
!Object.values(WORKFLOWS_TAB).includes(selectedTabId)
6766
) {
68-
if (Object.keys(workflows).length > 0) {
69-
setSelectedTabId(WORKFLOWS_TAB.MANAGE);
70-
replaceActiveTab(WORKFLOWS_TAB.MANAGE, props);
71-
} else {
72-
setSelectedTabId(WORKFLOWS_TAB.CREATE);
73-
replaceActiveTab(WORKFLOWS_TAB.CREATE, props);
74-
}
67+
setSelectedTabId(WORKFLOWS_TAB.MANAGE);
68+
replaceActiveTab(WORKFLOWS_TAB.MANAGE, props);
7569
}
7670
}, [selectedTabId, workflows]);
7771

0 commit comments

Comments
 (0)
Please sign in to comment.