Skip to content

Commit

Permalink
Make New Workflow page functional (#102)
Browse files Browse the repository at this point in the history
Signed-off-by: Tyler Ohlsen <[email protected]>
  • Loading branch information
ohltyler committed Mar 4, 2024
1 parent 1690766 commit 2d176f0
Show file tree
Hide file tree
Showing 10 changed files with 259 additions and 39 deletions.
4 changes: 3 additions & 1 deletion .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
14 changes: 9 additions & 5 deletions common/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 15 additions & 4 deletions public/pages/workflow_detail/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -22,14 +23,24 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) {

return (
<EuiPageHeader
pageTitle={props.workflow ? props.workflow.name : ''}
pageTitle={
props.workflow ? (
props.workflow.name
) : props.isNewWorkflow && !props.workflow ? (
DEFAULT_NEW_WORKFLOW_NAME
) : (
<EuiLoadingSpinner size="xl" />
)
}
rightSideItems={[
// TODO: add launch logic
<EuiButton fill={false} onClick={() => {}}>
Prototype
Launch
</EuiButton>,
<EuiButton
fill={false}
disabled={!props.workflow || !isDirty}
// TODO: if isNewWorkflow is true, clear the workflow cache if saving is successful.
onClick={() => {
// @ts-ignore
saveWorkflow(props.workflow, reactFlowInstance);
Expand Down
47 changes: 41 additions & 6 deletions public/pages/workflow_detail/workflow_detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -112,7 +143,11 @@ export function WorkflowDetail(props: WorkflowDetailProps) {
<ReactFlowProvider>
<EuiPage>
<EuiPageBody>
<WorkflowDetailHeader workflow={workflow} tabs={tabs} />
<WorkflowDetailHeader
workflow={workflow}
isNewWorkflow={isNewWorkflow}
tabs={tabs}
/>
{selectedTabId === WORKFLOW_DETAILS_TAB.EDITOR && (
<ResizableWorkspace workflow={workflow} />
)}
Expand Down
109 changes: 90 additions & 19 deletions public/pages/workflows/new_workflow/new_workflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand All @@ -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<Workflow[]>(
getPresetWorkflows()
);

// search bar state
const [searchQuery, setSearchQuery] = useState<string>('');
const debounceSearchQuery = debounce((query: string) => {
setSearchQuery(query);
}, 200);

// When search query updated, re-filter preset list
useEffect(() => {
setFilteredWorkflows(fetchFilteredWorkflows(presetWorkflows, searchQuery));
}, [searchQuery]);

return (
<EuiFlexGrid columns={3} gutterSize="l">
<EuiFlexItem>
<UseCase
title="Semantic Search"
description="Semantic search description..."
/>
</EuiFlexItem>
<EuiFlexItem>
<UseCase
title="Multi-modal Search"
description="Multi-modal search description..."
<EuiFlexGroup direction="column">
<EuiFlexItem grow={true}>
<EuiFieldSearch
fullWidth={true}
placeholder="Search"
onChange={(e) => debounceSearchQuery(e.target.value)}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseCase
title="Search Summarization"
description="Search summarization description..."
/>
<EuiFlexGrid columns={3} gutterSize="l">
{filteredWorkflows.map((workflow: Workflow, index) => {
return (
<EuiFlexItem key={index}>
<UseCase
title={workflow.name}
description={workflow.description || ''}
onClick={() =>
dispatch(
cacheWorkflow({
...workflow,
name: processWorkflowName(workflow.name),
})
)
}
/>
</EuiFlexItem>
);
})}
</EuiFlexGrid>
</EuiFlexItem>
</EuiFlexGrid>
</EuiFlexGroup>
);
}

// 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('_');
}
63 changes: 63 additions & 0 deletions public/pages/workflows/new_workflow/presets.tsx
Original file line number Diff line number Diff line change
@@ -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[];
}
Loading

0 comments on commit 2d176f0

Please sign in to comment.