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/component_details.tsx b/public/pages/workflow_detail/component_details/component_details.tsx
index d106092f..7ecaed6e 100644
--- a/public/pages/workflow_detail/component_details/component_details.tsx
+++ b/public/pages/workflow_detail/component_details/component_details.tsx
@@ -13,6 +13,7 @@ import { EmptyComponentInputs } from './empty_component_inputs';
import '../workspace/workspace-styles.scss';
interface ComponentDetailsProps {
+ onFormChange: () => void;
selectedComponent?: ReactFlowComponent;
}
@@ -31,7 +32,10 @@ export function ComponentDetails(props: ComponentDetailsProps) {
{props.selectedComponent ? (
-
+
) : (
)}
diff --git a/public/pages/workflow_detail/component_details/component_inputs.tsx b/public/pages/workflow_detail/component_details/component_inputs.tsx
index bed3ad72..cae794df 100644
--- a/public/pages/workflow_detail/component_details/component_inputs.tsx
+++ b/public/pages/workflow_detail/component_details/component_inputs.tsx
@@ -10,6 +10,7 @@ import { ReactFlowComponent } from '../../../../common';
interface ComponentInputsProps {
selectedComponent: ReactFlowComponent;
+ onFormChange: () => void;
}
export function ComponentInputs(props: ComponentInputsProps) {
@@ -19,7 +20,10 @@ export function ComponentInputs(props: ComponentInputsProps) {
{props.selectedComponent.data.label || ''}
-
+
>
);
}
diff --git a/public/pages/workflow_detail/component_details/input_field_list.tsx b/public/pages/workflow_detail/component_details/input_field_list.tsx
index ced08c14..f7aed9d8 100644
--- a/public/pages/workflow_detail/component_details/input_field_list.tsx
+++ b/public/pages/workflow_detail/component_details/input_field_list.tsx
@@ -15,6 +15,7 @@ import { ReactFlowComponent } from '../../../../common';
interface InputFieldListProps {
selectedComponent: ReactFlowComponent;
+ onFormChange: () => void;
}
export function InputFieldList(props: InputFieldListProps) {
@@ -30,6 +31,7 @@ export function InputFieldList(props: InputFieldListProps) {
@@ -42,6 +44,7 @@ export function InputFieldList(props: InputFieldListProps) {
);
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 95d83772..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
@@ -36,6 +36,7 @@ const existingIndices = [
interface SelectFieldProps {
field: IComponentField;
componentId: string;
+ onFormChange: () => void;
}
/**
@@ -56,8 +57,8 @@ 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();
}}
isInvalid={isFieldInvalid(
props.componentId,
diff --git a/public/pages/workflow_detail/component_details/input_fields/text_field.tsx b/public/pages/workflow_detail/component_details/input_fields/text_field.tsx
index fec071ac..d3866a7e 100644
--- a/public/pages/workflow_detail/component_details/input_fields/text_field.tsx
+++ b/public/pages/workflow_detail/component_details/input_fields/text_field.tsx
@@ -17,6 +17,7 @@ import {
interface TextFieldProps {
field: IComponentField;
componentId: string;
+ onFormChange: () => void;
}
/**
@@ -46,6 +47,12 @@ export function TextField(props: TextFieldProps) {
placeholder={props.field.placeholder || ''}
compressed={false}
value={field.value || getInitialValue(props.field.type)}
+ onChange={(e) => form.setFieldValue(formField, e.target.value)}
+ // This is a design decision to only trigger form updates onBlur() instead
+ // of onChange(). This is to rate limit the number of updates & re-renders made, as users
+ // typically rapidly type things into a text box, which would consequently trigger
+ // onChange() much more often.
+ onBlur={() => props.onFormChange()}
/>
);
diff --git a/public/pages/workflow_detail/components/header.tsx b/public/pages/workflow_detail/components/header.tsx
index 2b99e57e..94b63195 100644
--- a/public/pages/workflow_detail/components/header.tsx
+++ b/public/pages/workflow_detail/components/header.tsx
@@ -33,21 +33,9 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) {
)
}
rightSideItems={[
- // TODO: add launch logic
- {}}>
- Launch
- ,
- {
- // @ts-ignore
- saveWorkflow(props.workflow, reactFlowInstance);
- dispatch(removeDirty());
- }}
- >
- Save
+ // TODO: finalize if this is needed
+ {}}>
+ Delete
,
]}
tabs={props.tabs}
diff --git a/public/pages/workflow_detail/utils/utils.ts b/public/pages/workflow_detail/utils/utils.ts
index f536ab25..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(workflow: Workflow, rfInstance: any): 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.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/workflow_detail.tsx b/public/pages/workflow_detail/workflow_detail.tsx
index 38e15f2e..40b11474 100644
--- a/public/pages/workflow_detail/workflow_detail.tsx
+++ b/public/pages/workflow_detail/workflow_detail.tsx
@@ -98,22 +98,11 @@ export function WorkflowDetail(props: WorkflowDetailProps) {
// On initial load:
// - fetch workflow, if there is an existing workflow ID
- // - add a window listener to warn users if they exit/refresh
- // without saving latest changes
useEffect(() => {
if (!isNewWorkflow) {
// TODO: can optimize to only fetch a single workflow
dispatch(searchWorkflows({ query: { match_all: {} } }));
}
-
- // TODO: below has the following issue:
- // 1. user starts to create new unsaved workflow changes
- // 2. user navigates to other parts of the plugin without refreshing - no warning happens
- // 3. user refreshes at any later time: if isDirty is still true, shows browser warning
- // tune to only handle the check if still on the workflow details page, or consider adding a check / warning
- // if navigating away from the details page without refreshing (where it is currently not being triggered)
- // window.onbeforeunload = (e) =>
- // isDirty || isNewWorkflow ? true : undefined;
}, []);
const tabs = [
@@ -156,7 +145,10 @@ export function WorkflowDetail(props: WorkflowDetailProps) {
tabs={tabs}
/>
{selectedTabId === WORKFLOW_DETAILS_TAB.EDITOR && (
-
+
)}
{selectedTabId === WORKFLOW_DETAILS_TAB.LAUNCHES && }
{selectedTabId === WORKFLOW_DETAILS_TAB.PROTOTYPE && (
diff --git a/public/pages/workflow_detail/workspace/resizable_workspace.tsx b/public/pages/workflow_detail/workspace/resizable_workspace.tsx
index 983e36f0..b827fdc9 100644
--- a/public/pages/workflow_detail/workspace/resizable_workspace.tsx
+++ b/public/pages/workflow_detail/workspace/resizable_workspace.tsx
@@ -4,11 +4,17 @@
*/
import React, { useRef, useState, useEffect, useContext } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
import { useOnSelectionChange } from 'reactflow';
import { Form, Formik } from 'formik';
import * as yup from 'yup';
import { cloneDeep } from 'lodash';
-import { EuiButton, EuiResizableContainer } from '@elastic/eui';
+import {
+ EuiButton,
+ EuiCallOut,
+ EuiPageHeader,
+ EuiResizableContainer,
+} from '@elastic/eui';
import {
Workflow,
WorkspaceFormValues,
@@ -17,12 +23,18 @@ import {
WorkspaceSchemaObj,
componentDataToFormik,
getComponentSchema,
+ toWorkspaceFlow,
+ validateWorkspaceFlow,
+ WorkspaceFlowState,
+ toTemplateFlows,
} from '../../../../common';
-import { rfContext } from '../../../store';
+import { AppState, removeDirty, setDirty, rfContext } from '../../../store';
import { Workspace } from './workspace';
import { ComponentDetails } from '../component_details';
+import { processNodes, saveWorkflow } from '../utils';
interface ResizableWorkspaceProps {
+ isNewWorkflow: boolean;
workflow?: Workflow;
}
@@ -33,6 +45,27 @@ const COMPONENT_DETAILS_PANEL_ID = 'component_details_panel_id';
* panels - the ReactFlow workspace panel and the selected component details panel.
*/
export function ResizableWorkspace(props: ResizableWorkspaceProps) {
+ const dispatch = useDispatch();
+
+ // Overall workspace state
+ const isDirty = useSelector((state: AppState) => state.workspace.isDirty);
+ 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(
@@ -68,6 +101,25 @@ 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. If so,
+ // generate a default one based on the 'workflows' JSON field.
+ 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) => {
@@ -80,16 +132,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);
});
@@ -97,7 +145,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.
@@ -129,6 +177,16 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
setFormSchema(updatedSchema);
}
+ /**
+ * Function to pass down to the Formik ,
+ {
+ 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
+ ,
+ ]}
+ bottomBorder={false}
+ />
{(EuiResizablePanel, EuiResizableButton, { togglePanel }) => {
if (togglePanel) {
@@ -153,17 +286,18 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
<>
onToggleChange()}
>
-
+
>
);
}}
- 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);
}
diff --git a/public/pages/workflows/workflows.test.tsx b/public/pages/workflows/workflows.test.tsx
index 3c12244c..cdf14c57 100644
--- a/public/pages/workflows/workflows.test.tsx
+++ b/public/pages/workflows/workflows.test.tsx
@@ -40,7 +40,7 @@ const renderWithRouter = () => ({
describe('Workflows', () => {
test('renders the page', () => {
- const { getByText } = renderWithRouter();
- expect(getByText('Workflows')).not.toBeNull();
+ const { getAllByText } = renderWithRouter();
+ expect(getAllByText('Workflows').length).toBeGreaterThan(0);
});
});
diff --git a/public/pages/workflows/workflows.tsx b/public/pages/workflows/workflows.tsx
index 64f5f3f2..b745d888 100644
--- a/public/pages/workflows/workflows.tsx
+++ b/public/pages/workflows/workflows.tsx
@@ -58,20 +58,14 @@ export function Workflows(props: WorkflowsProps) {
] as WORKFLOWS_TAB;
const [selectedTabId, setSelectedTabId] = useState(tabFromUrl);
- // If there is no selected tab or invalid tab, default to a tab depending
- // on if user has existing created workflows or not.
+ // If there is no selected tab or invalid tab, default to manage tab
useEffect(() => {
if (
!selectedTabId ||
!Object.values(WORKFLOWS_TAB).includes(selectedTabId)
) {
- if (Object.keys(workflows).length > 0) {
- setSelectedTabId(WORKFLOWS_TAB.MANAGE);
- replaceActiveTab(WORKFLOWS_TAB.MANAGE, props);
- } else {
- setSelectedTabId(WORKFLOWS_TAB.CREATE);
- replaceActiveTab(WORKFLOWS_TAB.CREATE, props);
- }
+ setSelectedTabId(WORKFLOWS_TAB.MANAGE);
+ replaceActiveTab(WORKFLOWS_TAB.MANAGE, props);
}
}, [selectedTabId, workflows]);