diff --git a/.github/workflows/remote-integ-tests-workflow.yml b/.github/workflows/remote-integ-tests-workflow.yml index ced1d7ee..bbca9348 100644 --- a/.github/workflows/remote-integ-tests-workflow.yml +++ b/.github/workflows/remote-integ-tests-workflow.yml @@ -72,37 +72,48 @@ jobs: node-version: ${{ steps.tool-versions.outputs.node_version }} registry-url: 'https://registry.npmjs.org' - - name: Setup Opensearch Dashboards + - name: Install correct yarn version for OpenSearch Dashboards + id: setup-yarn run: | npm uninstall -g yarn - echo "Installing yarn ${{ steps.tool-versions.outputs.yarn_version }}" - npm i -g yarn@${{ steps.tool-versions.outputs.yarn_version }} - yarn cache clean - yarn add sha.js - working-directory: OpenSearch-Dashboards - shell: bash + echo "Installing yarn ${{ steps.versions_step.outputs.yarn_version }}" + npm i -g yarn@${{ steps.versions_step.outputs.yarn_version }} - - name: Bootstrap Opensearch Dashboards + - name: Yarn Cache + uses: actions/cache@v4 + with: + path: | + OpenSearch-Dashboards/**/target + OpenSearch-Dashboards/**/node_modules + key: ${{ runner.OS }}-build-${{ hashFiles('OpenSearch-Dashboards/**/yarn.lock') }} + restore-keys: | + ${{ runner.OS }}-build- + + - name: Bootstrap OpenSearch Dashboards run: | + cd OpenSearch-Dashboards yarn osd bootstrap --single-version=loose - working-directory: OpenSearch-Dashboards/plugins/dashboards-flow-framework + - name: Compile OpenSearch Dashboards + run: | + cd OpenSearch-Dashboards + node scripts/build_opensearch_dashboards_platform_plugins --no-examples --workers=10 --verbose + - name: Run Opensearch Dashboards with FF Installed run: | nohup yarn start --no-base-path --no-watch --server.host="0.0.0.0" | tee dashboard.log & - sleep 60 working-directory: OpenSearch-Dashboards - - name : Check If OpenSearch Dashboards Is Ready + - name: Check If OpenSearch Dashboards Is Ready if: ${{ runner.os == 'Linux' }} run: | - if timeout 600 grep -q "bundles compiled successfully after" <(tail -n0 -f dashboard.log); then - echo "OpenSearch Dashboards compiled successfully." + cd ./OpenSearch-Dashboards + if timeout 60 grep -q "http server running" <(tail -n +1 -f dashboard.log); then + echo "OpenSearch Dashboards started successfully." else - echo "Timeout for 600 seconds reached. OpenSearch Dashboards did not finish compiling." + echo "Timeout of 60 seconds reached. OpenSearch Dashboards did not start successfully." exit 1 - fi - working-directory: OpenSearch-Dashboards + fi& - name: Show OpenSearch Dashboards Logs if: always() diff --git a/common/constants.ts b/common/constants.ts index 9b3880e1..03c9e175 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -480,3 +480,7 @@ export const MODEL_OUTPUT_SCHEMA_NESTED_PATH = 'output.properties.inference_results.items.properties.output.items.properties.dataAsMap.properties'; export const MODEL_OUTPUT_SCHEMA_FULL_PATH = 'output.properties'; export const PROMPT_FIELD = 'prompt'; // TODO: likely expand to support a pattern and/or multiple (e.g., "prompt", "prompt_template", etc.) +export enum CONFIG_STEP { + INGEST = 'Ingestion pipeline', + SEARCH = 'Search pipeline', +} diff --git a/public/pages/workflow_detail/components/header.tsx b/public/pages/workflow_detail/components/header.tsx index 0d5dc423..294ef30f 100644 --- a/public/pages/workflow_detail/components/header.tsx +++ b/public/pages/workflow_detail/components/header.tsx @@ -12,6 +12,7 @@ import { EuiText, EuiSmallButtonEmpty, EuiSmallButton, + EuiSmallButtonIcon, } from '@elastic/eui'; import { PLUGIN_ID, @@ -19,6 +20,10 @@ import { Workflow, getCharacterLimitedString, toFormattedDate, + WorkflowConfig, + WorkflowTemplate, + WorkflowFormValues, + CONFIG_STEP, } from '../../../../common'; import { APP_PATH, @@ -26,6 +31,7 @@ import { constructUrlWithParams, getDataSourceId, dataSourceFilterFn, + formikToUiConfig, } from '../../../utils'; import { ExportModal } from './export_modal'; import { @@ -42,21 +48,43 @@ import { DataSourceViewConfig } from '../../../../../../src/plugins/data_source_ import { HeaderVariant } from '../../../../../../src/core/public'; import { TopNavControlTextData, - TopNavMenuData, TopNavMenuIconData, } from '../../../../../../src/plugins/navigation/public'; import { MountPoint } from '../../../../../../src/core/public'; +import { getWorkflow, updateWorkflow, useAppDispatch } from '../../../store'; +import { useFormikContext } from 'formik'; +import { isEmpty, isEqual } from 'lodash'; interface WorkflowDetailHeaderProps { workflow?: Workflow; + uiConfig: WorkflowConfig | undefined; + setUiConfig: (uiConfig: WorkflowConfig) => void; + isRunningIngest: boolean; + isRunningSearch: boolean; + selectedStep: CONFIG_STEP; + unsavedIngestProcessors: boolean; + setUnsavedIngestProcessors: (unsavedIngestProcessors: boolean) => void; + unsavedSearchProcessors: boolean; + setUnsavedSearchProcessors: (unsavedSearchProcessors: boolean) => void; setActionMenu: (menuMount?: MountPoint) => void; } export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) { + const dispatch = useAppDispatch(); const history = useHistory(); + const { resetForm, setTouched, values, touched, dirty } = useFormikContext< + WorkflowFormValues + >(); + // workflow state - const [workflowName, setWorkflowName] = useState(''); - const [workflowLastUpdated, setWorkflowLastUpdated] = useState(''); + const workflowName = getCharacterLimitedString( + props.workflow?.name, + MAX_WORKFLOW_NAME_TO_DISPLAY + ); + const workflowLastUpdated = toFormattedDate( + // @ts-ignore + props.workflow?.lastUpdated + ).toString(); // export modal state const [isExportModalOpen, setIsExportModalOpen] = useState(false); @@ -69,26 +97,6 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) { chrome: { setHeaderVariant }, } = getCore(); - useEffect(() => { - if (props.workflow) { - setWorkflowName( - getCharacterLimitedString( - props.workflow.name, - MAX_WORKFLOW_NAME_TO_DISPLAY - ) - ); - try { - const formattedDate = toFormattedDate( - // @ts-ignore - props.workflow.lastUpdated - ).toString(); - setWorkflowLastUpdated(formattedDate); - } catch (err) { - setWorkflowLastUpdated(''); - } - } - }, [props.workflow]); - // When NewHomePage is enabled, use 'application' HeaderVariant; otherwise, use 'page' HeaderVariant (default). useEffect(() => { if (SHOW_ACTIONS_IN_HEADER) { @@ -109,29 +117,13 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) { ); }; - const topNavConfig: TopNavMenuData[] = [ - { - iconType: 'exportAction', - tooltip: 'Export', - ariaLabel: 'Export', - run: onExportButtonClick, - controlType: 'icon', - } as TopNavMenuIconData, - { - iconType: 'exit', - tooltip: 'Return to projects', - ariaLabel: 'Exit', - run: onExitButtonClick, - controlType: 'icon', - } as TopNavMenuIconData, - ]; - - let renderDataSourceComponent: ReactElement | null = null; + // get & render the data source component, if applicable + let DataSourceComponent: ReactElement | null = null; if (dataSourceEnabled && getDataSourceManagementPlugin()) { const DataSourceMenu = getDataSourceManagementPlugin().ui.getDataSourceMenu< DataSourceViewConfig >(); - renderDataSourceComponent = ( + DataSourceComponent = ( (false); + + // listener when ingest processors have been added/deleted. + // compare to the indexed/persisted workflow config + useEffect(() => { + props.setUnsavedIngestProcessors( + !isEqual( + props.uiConfig?.ingest?.enrich?.processors, + props.workflow?.ui_metadata?.config?.ingest?.enrich?.processors + ) + ); + }, [props.uiConfig?.ingest?.enrich?.processors?.length]); + + // listener when search processors have been added/deleted. + // compare to the indexed/persisted workflow config + useEffect(() => { + props.setUnsavedSearchProcessors( + !isEqual( + props.uiConfig?.search?.enrichRequest?.processors, + props.workflow?.ui_metadata?.config?.search?.enrichRequest?.processors + ) || + !isEqual( + props.uiConfig?.search?.enrichResponse?.processors, + props.workflow?.ui_metadata?.config?.search?.enrichResponse + ?.processors + ) + ); + }, [ + props.uiConfig?.search?.enrichRequest?.processors?.length, + props.uiConfig?.search?.enrichResponse?.processors?.length, + ]); + + // button eligibility states + const ingestUndoButtonDisabled = + isRunningSave || props.isRunningIngest + ? true + : props.unsavedIngestProcessors + ? false + : !dirty; + const ingestSaveButtonDisabled = ingestUndoButtonDisabled; + const searchUndoButtonDisabled = + isRunningSave || props.isRunningSearch + ? true + : props.unsavedSearchProcessors + ? false + : isEmpty(touched?.search) || !dirty; + const searchSaveButtonDisabled = searchUndoButtonDisabled; + const undoDisabled = + props.selectedStep === CONFIG_STEP.INGEST + ? ingestUndoButtonDisabled + : searchUndoButtonDisabled; + const saveDisabled = + props.selectedStep === CONFIG_STEP.INGEST + ? ingestSaveButtonDisabled + : searchSaveButtonDisabled; + + // Utility fn to update the workflow UI config only, based on the current form values. + // A get workflow API call is subsequently run to fetch the updated state. + async function updateWorkflowUiConfig() { + let success = false; + setIsRunningSave(true); + const updatedTemplate = { + name: props.workflow?.name, + ui_metadata: { + ...props.workflow?.ui_metadata, + config: formikToUiConfig(values, props.uiConfig as WorkflowConfig), + }, + } as WorkflowTemplate; + await dispatch( + updateWorkflow({ + apiBody: { + workflowId: props.workflow?.id as string, + workflowTemplate: updatedTemplate, + updateFields: true, + reprovision: false, + }, + dataSourceId, + }) + ) + .unwrap() + .then(async (result) => { + success = true; + props.setUnsavedIngestProcessors(false); + props.setUnsavedSearchProcessors(false); + setTouched({}); + new Promise((f) => setTimeout(f, 1000)).then(async () => { + dispatch( + getWorkflow({ + workflowId: props.workflow?.id as string, + dataSourceId, + }) + ); + }); + }) + .catch((error: any) => { + console.error('Error saving workflow: ', error); + }) + .finally(() => { + setIsRunningSave(false); + }); + return success; + } + + // Utility fn to revert any unsaved changes, reset the form + function revertUnsavedChanges(): void { + resetForm(); + if ( + (props.unsavedIngestProcessors || props.unsavedSearchProcessors) && + props.workflow?.ui_metadata?.config !== undefined + ) { + props.setUiConfig(props.workflow?.ui_metadata?.config); + } + } + return ( <> {isExportModalOpen && ( @@ -158,7 +265,38 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) { <> ) : ( <> - {dataSourceEnabled && renderDataSourceComponent} + {dataSourceEnabled && DataSourceComponent} Close , + { + updateWorkflowUiConfig(); + }} + > + {`Save`} + , + { + revertUnsavedChanges(); + }} + />, {`Last updated: ${workflowLastUpdated}`} , diff --git a/public/pages/workflow_detail/resizable_workspace.tsx b/public/pages/workflow_detail/resizable_workspace.tsx index 02045e98..7ed75b63 100644 --- a/public/pages/workflow_detail/resizable_workspace.tsx +++ b/public/pages/workflow_detail/resizable_workspace.tsx @@ -3,9 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useRef, useState, useEffect } from 'react'; -import { Form, Formik } from 'formik'; -import * as yup from 'yup'; +import React, { useEffect, useRef, useState } from 'react'; import { EuiCodeBlock, EuiEmptyPrompt, @@ -14,19 +12,16 @@ import { EuiResizableContainer, EuiText, } from '@elastic/eui'; - import { + CONFIG_STEP, Workflow, WorkflowConfig, - WorkflowFormValues, - WorkflowSchema, customStringify, } from '../../../common'; import { isValidUiWorkflow, reduceToTemplate, - uiConfigToFormik, - uiConfigToSchema, + SHOW_ACTIONS_IN_HEADER, } from '../../utils'; import { WorkflowInputs } from './workflow_inputs'; import { Workspace } from './workspace'; @@ -37,7 +32,19 @@ import './workspace/workspace-styles.scss'; import '../../global-styles.scss'; interface ResizableWorkspaceProps { - workflow?: Workflow; + workflow: Workflow | undefined; + uiConfig: WorkflowConfig | undefined; + setUiConfig: (uiConfig: WorkflowConfig) => void; + ingestDocs: string; + setIngestDocs: (docs: string) => void; + isRunningIngest: boolean; + setIsRunningIngest: (isRunningIngest: boolean) => void; + isRunningSearch: boolean; + setIsRunningSearch: (isRunningSearch: boolean) => void; + selectedStep: CONFIG_STEP; + setSelectedStep: (step: CONFIG_STEP) => void; + setUnsavedIngestProcessors: (unsavedIngestProcessors: boolean) => void; + setUnsavedSearchProcessors: (unsavedSearchProcessors: boolean) => void; } const WORKFLOW_INPUTS_PANEL_ID = 'workflow_inputs_panel_id'; @@ -49,27 +56,6 @@ const TOOLS_PANEL_ID = 'tools_panel_id'; * panels - the ReactFlow workspace panel and the selected component details panel. */ export function ResizableWorkspace(props: ResizableWorkspaceProps) { - // Workflow state - const [workflow, setWorkflow] = useState( - props.workflow - ); - - // Formik form state - const [formValues, setFormValues] = useState({}); - const [formSchema, setFormSchema] = useState(yup.object({})); - - // ingest state - const [ingestDocs, setIngestDocs] = useState(''); - - // query state - const [query, setQuery] = useState(''); - - // Temp UI config state. For persisting changes to the UI config that may - // not be saved in the backend (e.g., adding / removing an ingest processor) - const [uiConfig, setUiConfig] = useState( - undefined - ); - // Preview side panel state. This panel encapsulates the tools panel as a child resizable panel. const [isPreviewPanelOpen, setIsPreviewPanelOpen] = useState(true); const collapseFnHorizontal = useRef( @@ -82,12 +68,6 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { setIsPreviewPanelOpen(!isPreviewPanelOpen); }; - // ingest state - const [ingestResponse, setIngestResponse] = useState(''); - - // query state - const [queryResponse, setQueryResponse] = useState(''); - // Tools side panel state const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(true); const collapseFnVertical = useRef( @@ -98,177 +78,144 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { setIsToolsPanelOpen(!isToolsPanelOpen); }; - // workflow state - const [isValidWorkflow, setIsValidWorkflow] = useState(true); + // ingest / search response states to be populated in the Tools panel + const [ingestResponse, setIngestResponse] = useState(''); + const [queryResponse, setQueryResponse] = useState(''); - // Hook to check if the workflow is valid or not + // is valid workflow state, + associated hook to set it as such + const [isValidWorkflow, setIsValidWorkflow] = useState(true); useEffect(() => { const missingUiFlow = props.workflow && !isValidUiWorkflow(props.workflow); if (missingUiFlow) { setIsValidWorkflow(false); - } else { - setWorkflow(props.workflow); } }, [props.workflow]); - // Initialize the form state based on the workflow's config, if applicable. - useEffect(() => { - if (workflow?.ui_metadata?.config) { - setUiConfig(workflow.ui_metadata.config); - } - }, [workflow]); - - // Initialize the form state based on the current UI config - useEffect(() => { - if (uiConfig) { - const initFormValues = uiConfigToFormik(uiConfig, ingestDocs); - const initFormSchema = uiConfigToSchema(uiConfig); - setFormValues(initFormValues); - setFormSchema(initFormSchema); - } - }, [uiConfig]); - return isValidWorkflow ? ( - {}} - validate={(values) => {}} + - {(formikProps) => ( -
- - {(EuiResizablePanel, EuiResizableButton, { togglePanel }) => { - if (togglePanel) { - collapseFnHorizontal.current = ( - panelId: string, - { direction } - ) => togglePanel(panelId, { direction }); - } - - return ( - <> - - - - - onTogglePreviewChange()} - > - - {( - EuiResizablePanel, - EuiResizableButton, - { togglePanel } - ) => { - if (togglePanel) { - collapseFnVertical.current = ( - panelId: string, - { direction } - ) => - // ignore is added since docs are incorrectly missing "top" and "bottom" - // as valid direction options for vertically-configured resizable panels. - // @ts-ignore - togglePanel(panelId, { direction }); - } - - return ( - <> - - - - - - - - - - onToggleToolsChange() - } - style={{ marginBottom: '-16px' }} - > - - - - ); - }} - - - - ); - }} - -
- )} -
+ {(EuiResizablePanel, EuiResizableButton, { togglePanel }) => { + if (togglePanel) { + collapseFnHorizontal.current = (panelId: string, { direction }) => + togglePanel(panelId, { direction }); + } + return ( + <> + + + + + onTogglePreviewChange()} + > + + {(EuiResizablePanel, EuiResizableButton, { togglePanel }) => { + if (togglePanel) { + collapseFnVertical.current = ( + panelId: string, + { direction } + ) => + // ignore is added since docs are incorrectly missing "top" and "bottom" + // as valid direction options for vertically-configured resizable panels. + // @ts-ignore + togglePanel(panelId, { direction }); + } + return ( + <> + + + + + + + + + onToggleToolsChange()} + style={{ marginBottom: '-16px' }} + > + + + + ); + }} + + + + ); + }} + ) : ( diff --git a/public/pages/workflow_detail/workflow_detail.test.tsx b/public/pages/workflow_detail/workflow_detail.test.tsx index b5c4f237..dbeb7057 100644 --- a/public/pages/workflow_detail/workflow_detail.test.tsx +++ b/public/pages/workflow_detail/workflow_detail.test.tsx @@ -249,8 +249,7 @@ describe('WorkflowDetail Page with skip ingestion option (Hybrid Search Workflow expect(getAllByText('PROCESSORS').length).toBeGreaterThan(0); }); - // Save, Build and Run query, Back buttons - expect(getByTestId('saveSearchPipelineButton')).toBeInTheDocument(); + // Build and Run query, Back buttons are present expect(getByTestId('runQueryButton')).toBeInTheDocument(); const searchPipelineBackButton = getByTestId('searchPipelineBackButton'); userEvent.click(searchPipelineBackButton); diff --git a/public/pages/workflow_detail/workflow_detail.tsx b/public/pages/workflow_detail/workflow_detail.tsx index a594b253..6ed8eae2 100644 --- a/public/pages/workflow_detail/workflow_detail.tsx +++ b/public/pages/workflow_detail/workflow_detail.tsx @@ -3,11 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { ReactFlowProvider } from 'reactflow'; import { escape } from 'lodash'; +import { Formik } from 'formik'; +import * as yup from 'yup'; import { EuiSmallButton, EuiEmptyPrompt, @@ -16,7 +18,13 @@ import { EuiPage, EuiPageBody, } from '@elastic/eui'; -import { APP_PATH, BREADCRUMBS, SHOW_ACTIONS_IN_HEADER } from '../../utils'; +import { + APP_PATH, + BREADCRUMBS, + SHOW_ACTIONS_IN_HEADER, + uiConfigToFormik, + uiConfigToSchema, +} from '../../utils'; import { getCore } from '../../services'; import { WorkflowDetailHeader } from './components'; import { @@ -29,26 +37,28 @@ import { } from '../../store'; import { ResizableWorkspace } from './resizable_workspace'; import { + CONFIG_STEP, ERROR_GETTING_WORKFLOW_MSG, FETCH_ALL_QUERY, MAX_WORKFLOW_NAME_TO_DISPLAY, NO_TEMPLATES_FOUND_MSG, OMIT_SYSTEM_INDEX_PATTERN, + WorkflowConfig, + WorkflowFormValues, + WorkflowSchema, getCharacterLimitedString, } from '../../../common'; import { MountPoint } from '../../../../../src/core/public'; - -// styling -import './workflow-detail-styles.scss'; -import '../../global-styles.scss'; - import { constructHrefWithDataSourceId, getDataSourceId, } from '../../utils/utils'; - import { getDataSourceEnabled } from '../../services'; +// styling +import './workflow-detail-styles.scss'; +import '../../global-styles.scss'; + export interface WorkflowDetailRouterProps { workflowId: string; } @@ -66,6 +76,19 @@ interface WorkflowDetailProps export function WorkflowDetail(props: WorkflowDetailProps) { const dispatch = useAppDispatch(); + + // On initial load: + // - fetch workflow + // - fetch available models & connectors as their IDs may be used when building flows + // - fetch all indices + useEffect(() => { + dispatch(getWorkflow({ workflowId, dataSourceId })); + dispatch(searchModels({ apiBody: FETCH_ALL_QUERY, dataSourceId })); + dispatch(searchConnectors({ apiBody: FETCH_ALL_QUERY, dataSourceId })); + dispatch(catIndices({ pattern: OMIT_SYSTEM_INDEX_PATTERN, dataSourceId })); + }, []); + + // data-source-related states const dataSourceEnabled = getDataSourceEnabled().enabled; const dataSourceId = getDataSourceId(); const { workflows, errorMessage } = useSelector( @@ -80,6 +103,7 @@ export function WorkflowDetail(props: WorkflowDetailProps) { MAX_WORKFLOW_NAME_TO_DISPLAY ); + // setting breadcrumbs based on data source enabled const { chrome: { setBreadcrumbs }, } = getCore(); @@ -101,16 +125,49 @@ export function WorkflowDetail(props: WorkflowDetailProps) { ); }, [SHOW_ACTIONS_IN_HEADER, dataSourceEnabled, dataSourceId, workflowName]); - // On initial load: - // - fetch workflow - // - fetch available models & connectors as their IDs may be used when building flows - // - fetch all indices + // form state + const [formValues, setFormValues] = useState({}); + const [formSchema, setFormSchema] = useState(yup.object({})); + + // ingest docs state. we need to persist here to update the form values. + const [ingestDocs, setIngestDocs] = useState(''); + + // Temp UI config state. For persisting changes to the UI config that may + // not be saved in the backend (e.g., adding / removing an ingest processor) + const [uiConfig, setUiConfig] = useState( + undefined + ); + + // various form-related states. persisted here to pass down to the child's form and header components, particularly + // to have consistency on the button states (enabled/disabled) + const [isRunningIngest, setIsRunningIngest] = useState(false); + const [isRunningSearch, setIsRunningSearch] = useState(false); + const [selectedStep, setSelectedStep] = useState( + CONFIG_STEP.INGEST + ); + const [unsavedIngestProcessors, setUnsavedIngestProcessors] = useState< + boolean + >(false); + const [unsavedSearchProcessors, setUnsavedSearchProcessors] = useState< + boolean + >(false); + + // Initialize the UI config based on the workflow's config, if applicable. useEffect(() => { - dispatch(getWorkflow({ workflowId, dataSourceId })); - dispatch(searchModels({ apiBody: FETCH_ALL_QUERY, dataSourceId })); - dispatch(searchConnectors({ apiBody: FETCH_ALL_QUERY, dataSourceId })); - dispatch(catIndices({ pattern: OMIT_SYSTEM_INDEX_PATTERN, dataSourceId })); - }, []); + if (workflow?.ui_metadata?.config) { + setUiConfig(workflow.ui_metadata.config); + } + }, [workflow]); + + // Initialize the form state based on the current UI config, if applicable + useEffect(() => { + if (uiConfig) { + const initFormValues = uiConfigToFormik(uiConfig, ingestDocs); + const initFormSchema = uiConfigToSchema(uiConfig); + setFormValues(initFormValues); + setFormSchema(initFormSchema); + } + }, [uiConfig]); return errorMessage.includes(ERROR_GETTING_WORKFLOW_MSG) || errorMessage.includes(NO_TEMPLATES_FOUND_MSG) ? ( @@ -133,18 +190,47 @@ export function WorkflowDetail(props: WorkflowDetailProps) { ) : ( - - - - - - - - - - + {}} + validate={(values) => {}} + > + + + + + + + + + ); } diff --git a/public/pages/workflow_detail/workflow_inputs/workflow_inputs.tsx b/public/pages/workflow_detail/workflow_inputs/workflow_inputs.tsx index 9f1808ec..3d3ce35f 100644 --- a/public/pages/workflow_detail/workflow_inputs/workflow_inputs.tsx +++ b/public/pages/workflow_detail/workflow_inputs/workflow_inputs.tsx @@ -22,9 +22,9 @@ import { EuiPanel, EuiStepsHorizontal, EuiText, - EuiSmallButtonIcon, } from '@elastic/eui'; import { + CONFIG_STEP, MAX_WORKFLOW_NAME_TO_DISPLAY, SearchHit, TemplateNode, @@ -72,13 +72,14 @@ interface WorkflowInputsProps { setQueryResponse: (queryResponse: string) => void; ingestDocs: string; setIngestDocs: (docs: string) => void; - query: string; - setQuery: (query: string) => void; -} - -enum STEP { - INGEST = 'Ingestion pipeline', - SEARCH = 'Search pipeline', + isRunningIngest: boolean; + setIsRunningIngest: (isRunningIngest: boolean) => void; + isRunningSearch: boolean; + setIsRunningSearch: (isRunningSearch: boolean) => void; + selectedStep: CONFIG_STEP; + setSelectedStep: (step: CONFIG_STEP) => void; + setUnsavedIngestProcessors: (unsavedIngestProcessors: boolean) => void; + setUnsavedSearchProcessors: (unsavedSearchProcessors: boolean) => void; } enum INGEST_OPTION { @@ -95,25 +96,19 @@ export function WorkflowInputs(props: WorkflowInputsProps) { const { submitForm, validateForm, - resetForm, setFieldValue, setTouched, values, - touched, - dirty, } = useFormikContext(); const dispatch = useAppDispatch(); const dataSourceId = getDataSourceId(); + // query state + const [query, setQuery] = useState(''); + // transient running states - const [isRunningSave, setIsRunningSave] = useState(false); - const [isRunningIngest, setIsRunningIngest] = useState(false); - const [isRunningSearch, setIsRunningSearch] = useState(false); const [isRunningDelete, setIsRunningDelete] = useState(false); - // selected step state - const [selectedStep, setSelectedStep] = useState(STEP.INGEST); - // provisioned resources states const [ingestProvisioned, setIngestProvisioned] = useState(false); @@ -126,8 +121,8 @@ export function WorkflowInputs(props: WorkflowInputsProps) { ); // maintain global states - const onIngest = selectedStep === STEP.INGEST; - const onSearch = selectedStep === STEP.SEARCH; + const onIngest = props.selectedStep === CONFIG_STEP.INGEST; + const onSearch = props.selectedStep === CONFIG_STEP.SEARCH; const ingestEnabled = values?.ingest?.enabled || false; const onIngestAndProvisioned = onIngest && ingestProvisioned; const onIngestAndUnprovisioned = onIngest && !ingestProvisioned; @@ -167,42 +162,6 @@ export function WorkflowInputs(props: WorkflowInputsProps) { const [searchTemplatesDifferent, setSearchTemplatesDifferent] = useState< boolean >(false); - const [unsavedIngestProcessors, setUnsavedIngestProcessors] = useState< - boolean - >(false); - const [unsavedSearchProcessors, setUnsavedSearchProcessors] = useState< - boolean - >(false); - - // listener when ingest processors have been added/deleted. - // compare to the indexed/persisted workflow config - useEffect(() => { - setUnsavedIngestProcessors( - !isEqual( - props.uiConfig?.ingest?.enrich?.processors, - props.workflow?.ui_metadata?.config?.ingest?.enrich?.processors - ) - ); - }, [props.uiConfig?.ingest?.enrich?.processors?.length]); - - // listener when search processors have been added/deleted. - // compare to the indexed/persisted workflow config - useEffect(() => { - setUnsavedSearchProcessors( - !isEqual( - props.uiConfig?.search?.enrichRequest?.processors, - props.workflow?.ui_metadata?.config?.search?.enrichRequest?.processors - ) || - !isEqual( - props.uiConfig?.search?.enrichResponse?.processors, - props.workflow?.ui_metadata?.config?.search?.enrichResponse - ?.processors - ) - ); - }, [ - props.uiConfig?.search?.enrichRequest?.processors?.length, - props.uiConfig?.search?.enrichResponse?.processors?.length, - ]); // fetch the total template nodes useEffect(() => { @@ -283,30 +242,16 @@ export function WorkflowInputs(props: WorkflowInputsProps) { }, [props.workflow]); // maintain global states (button eligibility) - const ingestUndoButtonDisabled = - isRunningSave || isRunningIngest - ? true - : unsavedIngestProcessors - ? false - : !dirty; - const ingestSaveButtonDisabled = ingestUndoButtonDisabled; const ingestRunButtonDisabled = !ingestTemplatesDifferent; const ingestToSearchButtonDisabled = - ingestTemplatesDifferent || isRunningIngest; + ingestTemplatesDifferent || props.isRunningIngest; const searchBackButtonDisabled = - isRunningSearch || + props.isRunningSearch || (isProposingNoSearchResources || !ingestProvisioned ? false : searchTemplatesDifferent); - const searchUndoButtonDisabled = - isRunningSave || isRunningSearch - ? true - : unsavedSearchProcessors - ? false - : isEmpty(touched?.search) || !dirty; - const searchSaveButtonDisabled = searchUndoButtonDisabled; const searchRunButtonDisabled = - isRunningSearch || + props.isRunningSearch || (isProposingNoSearchResources && hasProvisionedSearchResources(props.workflow)); @@ -314,7 +259,6 @@ export function WorkflowInputs(props: WorkflowInputsProps) { // A get workflow API call is subsequently run to fetch the updated state. async function updateWorkflowUiConfig() { let success = false; - setIsRunningSave(true); const updatedTemplate = { name: props.workflow?.name, ui_metadata: { @@ -336,8 +280,8 @@ export function WorkflowInputs(props: WorkflowInputsProps) { .unwrap() .then(async (result) => { success = true; - setUnsavedIngestProcessors(false); - setUnsavedSearchProcessors(false); + props.setUnsavedIngestProcessors(false); + props.setUnsavedSearchProcessors(false); setTouched({}); new Promise((f) => setTimeout(f, 1000)).then(async () => { dispatch( @@ -350,24 +294,10 @@ export function WorkflowInputs(props: WorkflowInputsProps) { }) .catch((error: any) => { console.error('Error saving workflow: ', error); - }) - .finally(() => { - setIsRunningSave(false); }); return success; } - // Utility fn to revert any unsaved changes, reset the form - function revertUnsavedChanges(): void { - resetForm(); - if ( - (unsavedIngestProcessors || unsavedSearchProcessors) && - props.workflow?.ui_metadata?.config !== undefined - ) { - props.setUiConfig(props.workflow?.ui_metadata?.config); - } - } - // Utility fn to update the workflow, including any updated/new resources. // The reprovision param is used to determine whether we are doing full // deprovision/update/provision, vs. update w/ reprovision (fine-grained provisioning). @@ -393,8 +323,8 @@ export function WorkflowInputs(props: WorkflowInputsProps) { .unwrap() .then(async (result) => { await sleep(1000); - setUnsavedIngestProcessors(false); - setUnsavedSearchProcessors(false); + props.setUnsavedIngestProcessors(false); + props.setUnsavedSearchProcessors(false); success = true; // Kicking off an async task to re-fetch the workflow details // after some amount of time. Provisioning will finish in an indeterminate @@ -438,8 +368,8 @@ export function WorkflowInputs(props: WorkflowInputsProps) { .unwrap() .then(async (result) => { await sleep(1000); - setUnsavedIngestProcessors(false); - setUnsavedSearchProcessors(false); + props.setUnsavedIngestProcessors(false); + props.setUnsavedSearchProcessors(false); await dispatch( provisionWorkflow({ workflowId: updatedWorkflow.id as string, @@ -534,7 +464,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) { // to clean up any created resources and not have leftover / stale data in some index. // This is propagated by passing `reprovision=false` to validateAndUpdateWorkflow() async function validateAndRunIngestion(): Promise { - setIsRunningIngest(true); + props.setIsRunningIngest(true); let success = false; try { let ingestDocsObjs = [] as {}[]; @@ -567,7 +497,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) { } catch (error) { console.error('Error ingesting documents: ', error); } - setIsRunningIngest(false); + props.setIsRunningIngest(false); return success; } @@ -579,12 +509,12 @@ export function WorkflowInputs(props: WorkflowInputsProps) { // This logic is propagated by passing `reprovision=true/false` in the // validateAndUpdateWorkflow() fn calls below. async function validateAndRunQuery(): Promise { - setIsRunningSearch(true); + props.setIsRunningSearch(true); let success = false; try { let queryObj = {}; try { - queryObj = JSON.parse(props.query); + queryObj = JSON.parse(query); } catch (e) {} if (!isEmpty(queryObj)) { if (hasProvisionedIngestResources(props.workflow)) { @@ -597,7 +527,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) { const indexName = values.search.index.name; dispatch( searchIndex({ - apiBody: { index: indexName, body: props.query }, + apiBody: { index: indexName, body: query }, dataSourceId, }) ) @@ -620,7 +550,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) { } catch (error) { console.error('Error running query: ', error); } - setIsRunningSearch(false); + props.setIsRunningSearch(false); return success; } @@ -644,13 +574,13 @@ export function WorkflowInputs(props: WorkflowInputsProps) { {}, }, { - title: STEP.SEARCH, + title: CONFIG_STEP.SEARCH, isComplete: false, isSelected: onSearch, onClick: () => {}, @@ -803,7 +733,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) { )} @@ -824,7 +754,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) { fill={true} disabled={false} onClick={() => { - setSelectedStep(STEP.SEARCH); + props.setSelectedStep(CONFIG_STEP.SEARCH); }} data-testid="searchPipelineButton" > @@ -833,27 +763,6 @@ export function WorkflowInputs(props: WorkflowInputsProps) { ) : onIngest ? ( <> - - { - revertUnsavedChanges(); - }} - /> - - - { - updateWorkflowUiConfig(); - }} - > - {`Save`} - - Build and run ingestion @@ -871,7 +780,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) { { - setSelectedStep(STEP.SEARCH); + props.setSelectedStep(CONFIG_STEP.SEARCH); }} data-testid="searchPipelineButton" disabled={ingestToSearchButtonDisabled} @@ -885,38 +794,18 @@ export function WorkflowInputs(props: WorkflowInputsProps) { setSelectedStep(STEP.INGEST)} + onClick={() => + props.setSelectedStep(CONFIG_STEP.INGEST) + } data-testid="searchPipelineBackButton" > Back - - { - revertUnsavedChanges(); - }} - /> - - - { - updateWorkflowUiConfig(); - }} - data-testid="saveSearchPipelineButton" - > - {`Save`} - - { validateAndRunQuery(); diff --git a/test/utils.ts b/test/utils.ts index 980e05c5..8953288d 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -11,7 +11,7 @@ import { } from '../public/store'; import { WorkflowInput } from '../test/interfaces'; import { WORKFLOW_TYPE } from '../common/constants'; -import { UIState, Workflow } from '../common/interfaces'; +import { UIState, Workflow, WorkflowDict } from '../common/interfaces'; import { fetchEmptyMetadata, fetchHybridSearchMetadata, @@ -22,19 +22,17 @@ import fs from 'fs'; import path from 'path'; export function mockStore(...workflowSets: WorkflowInput[]) { + let workflowDict = {} as WorkflowDict; + workflowSets?.forEach((workflowInput) => { + workflowDict[workflowInput.id] = generateWorkflow(workflowInput); + }); return { getState: () => ({ opensearch: INITIAL_OPENSEARCH_STATE, ml: INITIAL_ML_STATE, workflows: { ...INITIAL_WORKFLOWS_STATE, - workflows: workflowSets.reduce( - (acc, workflowInput) => ({ - ...acc, - [workflowInput.id]: generateWorkflow(workflowInput), - }), - {} - ), + workflows: workflowDict, }, presets: INITIAL_PRESETS_STATE, }),