From 2d176f021eef973583b6081984cfb45770062611 Mon Sep 17 00:00:00 2001 From: Tyler Ohlsen Date: Mon, 4 Mar 2024 13:27:16 -0800 Subject: [PATCH] Make New Workflow page functional (#102) Signed-off-by: Tyler Ohlsen --- .github/workflows/build-and-test.yml | 4 +- common/constants.ts | 7 ++ common/interfaces.ts | 14 ++- .../workflow_detail/components/header.tsx | 19 ++- .../pages/workflow_detail/workflow_detail.tsx | 47 +++++++- .../workflows/new_workflow/new_workflow.tsx | 109 +++++++++++++++--- .../pages/workflows/new_workflow/presets.tsx | 63 ++++++++++ .../pages/workflows/new_workflow/use_case.tsx | 9 +- .../workflows/workflow_list/workflow_list.tsx | 2 +- public/store/reducers/workflows_reducer.ts | 24 ++++ 10 files changed, 259 insertions(+), 39 deletions(-) create mode 100644 public/pages/workflows/new_workflow/presets.tsx diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 676df60f..dc4b8230 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -16,6 +16,7 @@ jobs: product: opensearch-dashboards build-and-test-linux: + if: ${{ github.event.label.name != 'rapid' }} needs: Get-CI-Image-Tag name: Build & test strategy: @@ -49,7 +50,8 @@ jobs: # TODO: once github actions supports windows and macos docker containers, we can # merge these in to the above step's matrix, including adding windows support - build-and-test-windows-macos: + build-and-test-macos: + if: ${{ github.event.label.name != 'rapid' }} name: Build & test strategy: matrix: diff --git a/common/constants.ts b/common/constants.ts index 0903f38c..77d1cf52 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -29,3 +29,10 @@ export const SEARCH_WORKFLOWS_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/se export const GET_WORKFLOW_STATE_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/state`; export const CREATE_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/create`; export const DELETE_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/delete`; + +/** + * 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'; diff --git a/common/interfaces.ts b/common/interfaces.ts index a493d650..a927f0e8 100644 --- a/common/interfaces.ts +++ b/common/interfaces.ts @@ -69,16 +69,20 @@ export type UseCaseTemplate = { }; export type Workflow = { - id: string; + // won't exist until created in backend + id?: string; name: string; useCase: string; + template: UseCaseTemplate; description?: string; // ReactFlow state may not exist if a workflow is created via API/backend-only. workspaceFlowState?: WorkspaceFlowState; - template: UseCaseTemplate; - lastUpdated: number; - lastLaunched: number; - state: WORKFLOW_STATE; + // won't exist until created in backend + lastUpdated?: number; + // won't exist until launched/provisioned in backend + lastLaunched?: number; + // won't exist until launched/provisioned in backend + state?: WORKFLOW_STATE; }; export enum USE_CASE { diff --git a/public/pages/workflow_detail/components/header.tsx b/public/pages/workflow_detail/components/header.tsx index 987baeb5..2b99e57e 100644 --- a/public/pages/workflow_detail/components/header.tsx +++ b/public/pages/workflow_detail/components/header.tsx @@ -5,13 +5,14 @@ import React, { useContext } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { EuiPageHeader, EuiButton } from '@elastic/eui'; -import { Workflow } from '../../../../common'; +import { EuiPageHeader, EuiButton, EuiLoadingSpinner } from '@elastic/eui'; +import { DEFAULT_NEW_WORKFLOW_NAME, Workflow } from '../../../../common'; import { saveWorkflow } from '../utils'; import { rfContext, AppState, removeDirty } from '../../../store'; interface WorkflowDetailHeaderProps { tabs: any[]; + isNewWorkflow: boolean; workflow?: Workflow; } @@ -22,14 +23,24 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) { return ( + ) + } rightSideItems={[ + // TODO: add launch logic {}}> - Prototype + Launch , { // @ts-ignore saveWorkflow(props.workflow, reactFlowInstance); diff --git a/public/pages/workflow_detail/workflow_detail.tsx b/public/pages/workflow_detail/workflow_detail.tsx index e57bcc83..53f23372 100644 --- a/public/pages/workflow_detail/workflow_detail.tsx +++ b/public/pages/workflow_detail/workflow_detail.tsx @@ -5,17 +5,21 @@ import React, { useEffect, useState } from 'react'; import { RouteComponentProps, useLocation } from 'react-router-dom'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { ReactFlowProvider } from 'reactflow'; import queryString from 'query-string'; import { EuiPage, EuiPageBody } from '@elastic/eui'; import { BREADCRUMBS } from '../../utils'; import { getCore } from '../../services'; import { WorkflowDetailHeader } from './components'; -import { AppState } from '../../store'; +import { AppState, searchWorkflows } from '../../store'; import { ResizableWorkspace } from './workspace'; import { Launches } from './launches'; import { Prototype } from './prototype'; +import { + DEFAULT_NEW_WORKFLOW_NAME, + NEW_WORKFLOW_ID_URL, +} from '../../../common'; export interface WorkflowDetailRouterProps { workflowId: string; @@ -45,13 +49,27 @@ function replaceActiveTab(activeTab: string, props: 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 { workflows } = useSelector((state: AppState) => state.workflows); + const dispatch = useDispatch(); + const { workflows, cachedWorkflow } = useSelector( + (state: AppState) => state.workflows + ); + const { isDirty } = useSelector((state: AppState) => state.workspace); - const workflow = workflows[props.match?.params?.workflowId]; - const workflowName = workflow ? workflow.name : ''; + // 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 + : ''; + // tab state const tabFromUrl = queryString.parse(useLocation().search)[ ACTIVE_TAB_PARAM ] as WORKFLOW_DETAILS_TAB; @@ -78,6 +96,19 @@ 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: {} } })); + } + window.onbeforeunload = (e) => + isDirty || isNewWorkflow ? true : undefined; + }, []); + const tabs = [ { id: WORKFLOW_DETAILS_TAB.EDITOR, @@ -112,7 +143,11 @@ export function WorkflowDetail(props: WorkflowDetailProps) { - + {selectedTabId === WORKFLOW_DETAILS_TAB.EDITOR && ( )} diff --git a/public/pages/workflows/new_workflow/new_workflow.tsx b/public/pages/workflows/new_workflow/new_workflow.tsx index ce87c443..c255fd50 100644 --- a/public/pages/workflows/new_workflow/new_workflow.tsx +++ b/public/pages/workflows/new_workflow/new_workflow.tsx @@ -3,10 +3,23 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { EuiFlexItem, EuiFlexGrid } from '@elastic/eui'; - +import React, { useState, useEffect } from 'react'; +import { debounce } from 'lodash'; +import { + EuiFlexItem, + EuiFlexGrid, + EuiFlexGroup, + EuiFieldSearch, +} from '@elastic/eui'; +import { useDispatch } from 'react-redux'; import { UseCase } from './use_case'; +import { getPresetWorkflows } from './presets'; +import { + DEFAULT_NEW_WORKFLOW_NAME, + START_FROM_SCRATCH_WORKFLOW_NAME, + Workflow, +} from '../../../../common'; +import { cacheWorkflow } from '../../../store'; interface NewWorkflowProps {} @@ -18,26 +31,84 @@ interface NewWorkflowProps {} * workflow for users to start with. */ export function NewWorkflow(props: NewWorkflowProps) { + const dispatch = useDispatch(); + // preset workflow state + const presetWorkflows = getPresetWorkflows(); + const [filteredWorkflows, setFilteredWorkflows] = useState( + getPresetWorkflows() + ); + + // search bar state + const [searchQuery, setSearchQuery] = useState(''); + const debounceSearchQuery = debounce((query: string) => { + setSearchQuery(query); + }, 200); + + // When search query updated, re-filter preset list + useEffect(() => { + setFilteredWorkflows(fetchFilteredWorkflows(presetWorkflows, searchQuery)); + }, [searchQuery]); + return ( - - - - - - + + debounceSearchQuery(e.target.value)} /> - + + {filteredWorkflows.map((workflow: Workflow, index) => { + return ( + + + dispatch( + cacheWorkflow({ + ...workflow, + name: processWorkflowName(workflow.name), + }) + ) + } + /> + + ); + })} + - + ); } + +// Collect the final preset workflow list after applying all filters +function fetchFilteredWorkflows( + allWorkflows: Workflow[], + searchQuery: string +): Workflow[] { + return searchQuery.length === 0 + ? allWorkflows + : allWorkflows.filter((workflow) => + workflow.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); +} + +// Utility fn to process workflow names from their presentable/readable titles +// on the UI, to a valid name format. +// This leads to less friction if users decide to save the name later on. +function processWorkflowName(workflowName: string): string { + return workflowName === START_FROM_SCRATCH_WORKFLOW_NAME + ? DEFAULT_NEW_WORKFLOW_NAME + : toSnakeCase(workflowName); +} + +function toSnakeCase(text: string): string { + return text + .replace(/\W+/g, ' ') + .split(/ |\B(?=[A-Z])/) + .map((word) => word.toLowerCase()) + .join('_'); +} diff --git a/public/pages/workflows/new_workflow/presets.tsx b/public/pages/workflows/new_workflow/presets.tsx new file mode 100644 index 00000000..0cf7d382 --- /dev/null +++ b/public/pages/workflows/new_workflow/presets.tsx @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + START_FROM_SCRATCH_WORKFLOW_NAME, + Workflow, + WorkspaceFlowState, +} from '../../../../common'; + +// TODO: fetch from the backend when the workflow library is complete. +/** + * Used to fetch the library of preset workflows to provide to users. + */ +export function getPresetWorkflows(): Workflow[] { + return [ + { + name: 'Semantic Search', + description: + 'This semantic search workflow includes the essential ingestion and search pipelines that covers the most common search use cases.', + useCase: 'SEMANTIC_SEARCH', + template: {}, + workspaceFlowState: { + nodes: [], + edges: [], + } as WorkspaceFlowState, + }, + { + name: 'Semantic Search with Reranking', + description: + 'This semantic search workflow variation includes an ML processor to rerank fetched results.', + useCase: 'SEMANTIC_SEARCH_WITH_RERANK', + template: {}, + workspaceFlowState: { + nodes: [], + edges: [], + } as WorkspaceFlowState, + }, + { + name: START_FROM_SCRATCH_WORKFLOW_NAME, + description: + 'Build your workflow from scratch according to your specific use cases. Start by adding components for your ingest or query needs.', + useCase: '', + template: {}, + workspaceFlowState: { + nodes: [], + edges: [], + } as WorkspaceFlowState, + }, + { + name: 'Visual Search', + description: + 'Build an application that will return results based on images.', + useCase: 'SEMANTIC_SEARCH', + template: {}, + workspaceFlowState: { + nodes: [], + edges: [], + } as WorkspaceFlowState, + }, + ] as Workflow[]; +} diff --git a/public/pages/workflows/new_workflow/use_case.tsx b/public/pages/workflows/new_workflow/use_case.tsx index c7094c9a..111c4f7c 100644 --- a/public/pages/workflows/new_workflow/use_case.tsx +++ b/public/pages/workflows/new_workflow/use_case.tsx @@ -13,9 +13,12 @@ import { EuiHorizontalRule, EuiButton, } from '@elastic/eui'; +import { NEW_WORKFLOW_ID_URL, PLUGIN_ID } from '../../../../common'; + interface UseCaseProps { title: string; description: string; + onClick: () => {}; } export function UseCase(props: UseCaseProps) { @@ -28,6 +31,7 @@ export function UseCase(props: UseCaseProps) { } titleSize="s" paddingSize="l" + layout="horizontal" > @@ -39,9 +43,8 @@ export function UseCase(props: UseCaseProps) { { - // TODO: possibly link to the workflow details with a pre-configured flow - }} + onClick={props.onClick} + href={`${PLUGIN_ID}#/workflows/${NEW_WORKFLOW_ID_URL}`} > Go diff --git a/public/pages/workflows/workflow_list/workflow_list.tsx b/public/pages/workflows/workflow_list/workflow_list.tsx index 3e5b4484..f122a2e0 100644 --- a/public/pages/workflows/workflow_list/workflow_list.tsx +++ b/public/pages/workflows/workflow_list/workflow_list.tsx @@ -109,7 +109,7 @@ export function WorkflowList(props: WorkflowListProps) { debounceSearchQuery(e.target.value)} /> diff --git a/public/store/reducers/workflows_reducer.ts b/public/store/reducers/workflows_reducer.ts index 93f22e89..d06eac97 100644 --- a/public/store/reducers/workflows_reducer.ts +++ b/public/store/reducers/workflows_reducer.ts @@ -78,6 +78,7 @@ const initialState = { loading: false, errorMessage: '', workflows: {} as WorkflowDict, + cachedWorkflow: undefined as Workflow | undefined, }; const WORKFLOWS_ACTION_PREFIX = 'workflows'; @@ -86,6 +87,8 @@ const SEARCH_WORKFLOWS_ACTION = `${WORKFLOWS_ACTION_PREFIX}/searchWorkflows`; const GET_WORKFLOW_STATE_ACTION = `${WORKFLOWS_ACTION_PREFIX}/getWorkflowState`; const CREATE_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/createWorkflow`; const DELETE_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/deleteWorkflow`; +const CACHE_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/cacheWorkflow`; +const CLEAR_CACHED_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/clearCachedWorkflow`; export const getWorkflow = createAsyncThunk( GET_WORKFLOW_ACTION, @@ -167,6 +170,20 @@ export const deleteWorkflow = createAsyncThunk( } ); +export const cacheWorkflow = createAsyncThunk( + CACHE_WORKFLOW_ACTION, + async (workflow: Workflow) => { + return workflow; + } +); + +// A no-op function to trigger a reducer case. +// Will clear any stored workflow in the cachedWorkflow state +export const clearCachedWorkflow = createAsyncThunk( + CLEAR_CACHED_WORKFLOW_ACTION, + async () => {} +); + const workflowsSlice = createSlice({ name: 'workflows', initialState, @@ -238,6 +255,13 @@ const workflowsSlice = createSlice({ state.loading = false; state.errorMessage = ''; }) + .addCase(cacheWorkflow.fulfilled, (state, action) => { + const workflow = action.payload; + state.cachedWorkflow = workflow; + }) + .addCase(clearCachedWorkflow.fulfilled, (state, action) => { + state.cachedWorkflow = undefined; + }) // Rejected states: set state consistently across all actions .addCase(getWorkflow.rejected, (state, action) => { state.errorMessage = action.payload as string;