Skip to content

Commit

Permalink
Update create workflow, update workflow, and form validation logic (#164
Browse files Browse the repository at this point in the history
) (#165)

Signed-off-by: Tyler Ohlsen <[email protected]>
(cherry picked from commit 691c3b8)

Co-authored-by: Tyler Ohlsen <[email protected]>
  • Loading branch information
opensearch-trigger-bot[bot] and ohltyler committed Jun 5, 2024
1 parent cfd95b0 commit cb85127
Show file tree
Hide file tree
Showing 10 changed files with 213 additions and 240 deletions.
1 change: 0 additions & 1 deletion common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,6 @@ export enum COMPONENT_CLASS {
/**
* MISCELLANEOUS
*/
export const NEW_WORKFLOW_ID_URL = 'new';
export const START_FROM_SCRATCH_WORKFLOW_NAME = 'Start From Scratch';
export const DEFAULT_NEW_WORKFLOW_NAME = 'new_workflow';
export const DEFAULT_NEW_WORKFLOW_DESCRIPTION = 'My new workflow';
Expand Down
13 changes: 2 additions & 11 deletions public/pages/workflow_detail/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,16 @@ import {
} from '../../../../common';

interface WorkflowDetailHeaderProps {
isNewWorkflow: boolean;
workflow?: Workflow;
}

export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) {
function getTitle() {
return props.workflow
? props.workflow.name
: props.isNewWorkflow && !props.workflow
? DEFAULT_NEW_WORKFLOW_NAME
: '';
return props.workflow ? props.workflow.name : DEFAULT_NEW_WORKFLOW_NAME;
}

function getState() {
return props.workflow?.state
? props.workflow.state
: props.isNewWorkflow
? DEFAULT_NEW_WORKFLOW_STATE
: null;
return props.workflow ? props.workflow.state : DEFAULT_NEW_WORKFLOW_STATE;
}

return (
Expand Down
139 changes: 10 additions & 129 deletions public/pages/workflow_detail/resizable_workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,20 @@
import React, { useRef, useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { Form, Formik, FormikProps } from 'formik';
import { Form, Formik } from 'formik';
import * as yup from 'yup';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiResizableContainer,
EuiTitle,
} from '@elastic/eui';
import { getCore } from '../../services';

import {
Workflow,
WORKFLOW_STATE,
WorkflowFormValues,
WorkflowSchema,
WorkflowConfig,
} from '../../../common';
import {
APP_PATH,
uiConfigToFormik,
uiConfigToSchema,
formikToUiConfig,
reduceToTemplate,
} from '../../utils';
import {
AppState,
createWorkflow,
setDirty,
updateWorkflow,
useAppDispatch,
} from '../../store';
import { Workflow, WorkflowFormValues, WorkflowSchema } from '../../../common';
import { APP_PATH, uiConfigToFormik, uiConfigToSchema } from '../../utils';
import { AppState, setDirty, useAppDispatch } from '../../store';
import { WorkflowInputs } from './workflow_inputs';
import { configToTemplateFlows } from './utils';
import { Workspace } from './workspace';

// styling
Expand All @@ -48,7 +28,6 @@ import '../../global-styles.scss';
import { Tools } from './tools';

interface ResizableWorkspaceProps {
isNewWorkflow: boolean;
workflow?: Workflow;
}

Expand All @@ -66,7 +45,6 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
// Overall workspace state
const { isDirty } = useSelector((state: AppState) => state.workspace);
const { loading } = useSelector((state: AppState) => state.workflows);
const [isFirstSave, setIsFirstSave] = useState<boolean>(props.isNewWorkflow);

// Workflow state
const [workflow, setWorkflow] = useState<Workflow | undefined>(
Expand All @@ -77,9 +55,6 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
const [formValues, setFormValues] = useState<WorkflowFormValues>({});
const [formSchema, setFormSchema] = useState<WorkflowSchema>(yup.object({}));

// Validation states
const [formValidOnSubmit, setFormValidOnSubmit] = useState<boolean>(true);

// Workflow inputs side panel state
const [isWorkflowInputsPanelOpen, setIsWorkflowInputsPanelOpen] = useState<
boolean
Expand All @@ -104,56 +79,20 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
setIsToolsPanelOpen(!isToolsPanelOpen);
};

// Save/provision/deprovision button state
const isSaveable =
props.workflow !== undefined && (isFirstSave ? true : isDirty);
const isProvisionable =
props.workflow !== undefined &&
!isDirty &&
!props.isNewWorkflow &&
formValidOnSubmit &&
props.workflow?.state === WORKFLOW_STATE.NOT_STARTED;
const isDeprovisionable =
props.workflow !== undefined &&
!props.isNewWorkflow &&
props.workflow?.state !== WORKFLOW_STATE.NOT_STARTED;
// TODO: maybe remove this field. It depends on final UX if we want the
// workspace to be readonly once provisioned or not.
const readonly = props.workflow === undefined || isDeprovisionable;

// Loading state
const [isProvisioning, setIsProvisioning] = useState<boolean>(false);
const [isDeprovisioning, setIsDeprovisioning] = useState<boolean>(false);
const [isSaving, setIsSaving] = useState<boolean>(false);
const isCreating = isSaving && props.isNewWorkflow;
const isLoadingGlobal =
loading || isProvisioning || isDeprovisioning || isSaving || isCreating;

// Hook to update some default values for the workflow, if applicable.
// We need to handle different scenarios:
// 1. Rendering backend-only-created workflow / an already-created workflow with no ui_metadata.
// 1. Rendering backend-only-created workflow / an existing workflow with no ui_metadata.
// In this case, we revert to the home page with a warn toast that we don't support it, for now.
// This is because we initially have guardrails and a static set of readonly nodes/edges that we handle.
// 2. Rendering empty/null workflow, if refreshing the editor page where there is no cached workflow and
// no workflow ID in the URL.
// In this case, revert to home page and a warn toast that we don't support it for now.
// This is because we initially don't support building / drag-and-drop components.
// 3. Rendering a cached workflow via navigation from create workflow tab
// 4. Rendering a created workflow with ui_metadata.
// 2. Rendering a created workflow with ui_metadata.
// In these cases, just render what is persisted, no action needed.
useEffect(() => {
const missingUiFlow =
props.workflow && !props.workflow?.ui_metadata?.config;
const missingCachedWorkflow = props.isNewWorkflow && !props.workflow;
if (missingUiFlow || missingCachedWorkflow) {
if (missingUiFlow) {
history.replace(APP_PATH.WORKFLOWS);
if (missingCachedWorkflow) {
getCore().notifications.toasts.addWarning('No workflow found');
} else {
getCore().notifications.toasts.addWarning(
`There is no ui_metadata for workflow: ${props.workflow?.name}`
);
}
getCore().notifications.toasts.addWarning(
`There is no ui_metadata for workflow: ${props.workflow?.name}`
);
} else {
setWorkflow(props.workflow);
}
Expand All @@ -179,63 +118,6 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
}
}

// Utility validation fn used before executing any API calls (save, provision)
function validateAndSubmit(
formikProps: FormikProps<WorkflowFormValues>
): void {
// 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);
setIsSaving(false);
} else {
setFormValidOnSubmit(true);
const updatedConfig = formikToUiConfig(
formikProps.values,
workflow?.ui_metadata?.config as WorkflowConfig
);
const updatedWorkflow = {
...workflow,
ui_metadata: {
...workflow?.ui_metadata,
config: updatedConfig,
},
workflows: configToTemplateFlows(updatedConfig),
} as Workflow;
if (updatedWorkflow.id) {
dispatch(
updateWorkflow({
workflowId: updatedWorkflow.id,
workflowTemplate: reduceToTemplate(updatedWorkflow),
})
)
.unwrap()
.then((result) => {
setIsSaving(false);
})
.catch((error: any) => {
setIsSaving(false);
});
} else {
dispatch(createWorkflow(updatedWorkflow))
.unwrap()
.then((result) => {
const { workflow } = result;
history.replace(`${APP_PATH.WORKFLOWS}/${workflow.id}`);
history.go(0);
})
.catch((error: any) => {
setIsSaving(false);
});
}
}
});
}

return (
<Formik
enableReinitialize={true}
Expand Down Expand Up @@ -278,7 +160,6 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
workflow={props.workflow}
formikProps={formikProps}
onFormChange={onFormChange}
validateAndSubmit={validateAndSubmit}
/>
</EuiResizablePanel>
<EuiResizableButton />
Expand Down
30 changes: 8 additions & 22 deletions public/pages/workflow_detail/workflow_detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { ResizableWorkspace } from './resizable_workspace';
import {
DEFAULT_NEW_WORKFLOW_NAME,
FETCH_ALL_QUERY_BODY,
NEW_WORKFLOW_ID_URL,
} from '../../../common';

// styling
Expand All @@ -39,24 +38,18 @@ interface WorkflowDetailProps
* The workflow details page. This is where users will configure, create, and
* test their created workflows. Additionally, can be used to load existing workflows
* to view details and/or make changes to them.
* New, unsaved workflows are cached in the redux store and displayed here.
*/

export function WorkflowDetail(props: WorkflowDetailProps) {
const dispatch = useAppDispatch();
const { workflows, cachedWorkflow, errorMessage } = useSelector(
const { workflows, errorMessage } = useSelector(
(state: AppState) => state.workflows
);

// selected workflow state
const workflowId = props.match?.params?.workflowId;
const isNewWorkflow = workflowId === NEW_WORKFLOW_ID_URL;
const workflow = isNewWorkflow ? cachedWorkflow : workflows[workflowId];
const workflowName = workflow
? workflow.name
: isNewWorkflow && !workflow
? DEFAULT_NEW_WORKFLOW_NAME
: '';
const workflow = workflows[workflowId];
const workflowName = workflow ? workflow.name : DEFAULT_NEW_WORKFLOW_NAME;

useEffect(() => {
getCore().chrome.setBreadcrumbs([
Expand All @@ -67,12 +60,11 @@ export function WorkflowDetail(props: WorkflowDetailProps) {
});

// On initial load:
// - fetch workflow, if there is an existing workflow ID
// - fetch workflow
// - fetch available models as their IDs may be used when building flows
useEffect(() => {
if (!isNewWorkflow) {
dispatch(getWorkflow(workflowId));
}
dispatch(getWorkflow(workflowId));

dispatch(searchModels(FETCH_ALL_QUERY_BODY));
}, []);

Expand All @@ -88,15 +80,9 @@ export function WorkflowDetail(props: WorkflowDetailProps) {
<ReactFlowProvider>
<EuiPage>
<EuiPageBody className="workflow-detail stretch-relative">
<WorkflowDetailHeader
workflow={workflow}
isNewWorkflow={isNewWorkflow}
/>
<WorkflowDetailHeader workflow={workflow} />
<ReactFlowProvider>
<ResizableWorkspace
isNewWorkflow={isNewWorkflow}
workflow={workflow}
/>
<ResizableWorkspace workflow={workflow} />
</ReactFlowProvider>
</EuiPageBody>
</EuiPage>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { Workflow } from '../../../../../common';
interface IngestInputsProps {
workflow: Workflow;
onFormChange: () => void;
ingestDocs: {}[];
setIngestDocs: (docs: {}[]) => void;
}

/**
Expand All @@ -22,7 +24,10 @@ export function IngestInputs(props: IngestInputsProps) {
return (
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<SourceData />
<SourceData
ingestDocs={props.ingestDocs}
setIngestDocs={props.setIngestDocs}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="none" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,56 @@
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui';
import React, { useEffect, useState } from 'react';
import {
EuiCodeEditor,
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
} from '@elastic/eui';

interface SourceDataProps {}
interface SourceDataProps {
ingestDocs: {}[];
setIngestDocs: (docs: {}[]) => void;
}

/**
* Input component for configuring the source data for ingest.
*/
export function SourceData(props: SourceDataProps) {
const [jsonStr, setJsonStr] = useState<string>('{}');

useEffect(() => {
try {
const json = JSON.parse(jsonStr);
props.setIngestDocs([json]);
} catch (e) {}
}, [jsonStr]);

return (
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h4>Source data</h4>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiText grow={false}>TODO</EuiText>
<EuiFlexItem grow={false}>
<EuiCodeEditor
mode="json"
theme="textmate"
width="100%"
height="25vh"
value={jsonStr}
onChange={(input) => {
setJsonStr(input);
}}
readOnly={false}
setOptions={{
fontSize: '14px',
}}
aria-label="Code Editor"
tabSize={2}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
Expand Down
Loading

0 comments on commit cb85127

Please sign in to comment.