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;