diff --git a/common/interfaces.ts b/common/interfaces.ts index fd93cfbd..7ecffadc 100644 --- a/common/interfaces.ts +++ b/common/interfaces.ts @@ -76,9 +76,29 @@ export type Workflow = { workspaceFlowState?: WorkspaceFlowState; template: UseCaseTemplate; lastUpdated: number; + state: WORKFLOW_STATE; }; export enum USE_CASE { SEMANTIC_SEARCH = 'semantic_search', CUSTOM = 'custom', } + +/** + ********** MISC TYPES/INTERFACES ************ + */ + +// TODO: finalize how we have the launch data model +export type WorkflowLaunch = { + id: string; + state: WORKFLOW_STATE; + lastUpdated: number; +}; + +// TODO: finalize list of possible workflow states from backend +export enum WORKFLOW_STATE { + SUCCEEDED = 'Succeeded', + FAILED = 'Failed', + IN_PROGRESS = 'In progress', + NOT_STARTED = 'Not started', +} diff --git a/public/general_components/general-component-styles.scss b/public/general_components/general-component-styles.scss new file mode 100644 index 00000000..54e5c17b --- /dev/null +++ b/public/general_components/general-component-styles.scss @@ -0,0 +1,5 @@ +.multi-select-filter { + &--width { + width: 150px; + } +} diff --git a/public/general_components/index.ts b/public/general_components/index.ts new file mode 100644 index 00000000..20e4153c --- /dev/null +++ b/public/general_components/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { MultiSelectFilter } from './multi_select_filter'; diff --git a/public/general_components/multi_select_filter.tsx b/public/general_components/multi_select_filter.tsx new file mode 100644 index 00000000..5825da62 --- /dev/null +++ b/public/general_components/multi_select_filter.tsx @@ -0,0 +1,98 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiFilterSelectItem, + EuiFilterGroup, + EuiPopover, + EuiFilterButton, + EuiFlexItem, +} from '@elastic/eui'; + +// styling +import './general-component-styles.scss'; + +interface MultiSelectFilterProps { + title: string; + filters: EuiFilterSelectItem[]; + setSelectedFilters: (filters: EuiFilterSelectItem[]) => void; +} + +/** + * A general multi-select filter. + */ +export function MultiSelectFilter(props: MultiSelectFilterProps) { + const [filters, setFilters] = useState(props.filters); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + function onButtonClick() { + setIsPopoverOpen(!isPopoverOpen); + } + function onPopoverClose() { + setIsPopoverOpen(false); + } + + function updateFilter(index: number) { + if (!filters[index]) { + return; + } + const newFilters = [...filters]; + // @ts-ignore + newFilters[index].checked = + // @ts-ignore + newFilters[index].checked === 'on' ? undefined : 'on'; + + setFilters(newFilters); + props.setSelectedFilters( + // @ts-ignore + newFilters.filter((filter) => filter.checked === 'on') + ); + } + + return ( + + + filter.checked === 'on') + } + numActiveFilters={ + // @ts-ignore + filters.filter((filter) => filter.checked === 'on').length + } + > + {props.title} + + } + isOpen={isPopoverOpen} + closePopover={onPopoverClose} + panelPaddingSize="none" + > +
+ {filters.map((filter, index) => ( + updateFilter(index)} + > + {/* @ts-ignore */} + {filter.name} + + ))} +
+
+
+
+ ); +} diff --git a/public/pages/workflow_detail/components/header.tsx b/public/pages/workflow_detail/components/header.tsx index 6539952e..987baeb5 100644 --- a/public/pages/workflow_detail/components/header.tsx +++ b/public/pages/workflow_detail/components/header.tsx @@ -11,6 +11,7 @@ import { saveWorkflow } from '../utils'; import { rfContext, AppState, removeDirty } from '../../../store'; interface WorkflowDetailHeaderProps { + tabs: any[]; workflow?: Workflow; } @@ -22,7 +23,6 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) { return ( {}}> Prototype @@ -39,6 +39,8 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) { Save , ]} + tabs={props.tabs} + bottomBorder={true} /> ); } diff --git a/public/pages/workflow_detail/launches/columns.tsx b/public/pages/workflow_detail/launches/columns.tsx new file mode 100644 index 00000000..dfc5eea0 --- /dev/null +++ b/public/pages/workflow_detail/launches/columns.tsx @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const columns = [ + { + field: 'id', + name: 'Launch ID', + sortable: true, + }, + { + field: 'state', + name: 'Status', + sortable: true, + }, + { + field: 'lastUpdatedTime', + name: 'Last updated time', + sortable: true, + }, +]; diff --git a/public/pages/workflow_detail/launches/index.ts b/public/pages/workflow_detail/launches/index.ts new file mode 100644 index 00000000..4e21436b --- /dev/null +++ b/public/pages/workflow_detail/launches/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { Launches } from './launches'; diff --git a/public/pages/workflow_detail/launches/launch_details.tsx b/public/pages/workflow_detail/launches/launch_details.tsx new file mode 100644 index 00000000..82f62c90 --- /dev/null +++ b/public/pages/workflow_detail/launches/launch_details.tsx @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiText } from '@elastic/eui'; + +interface LaunchDetailsProps {} + +export function LaunchDetails(props: LaunchDetailsProps) { + return TODO: add selected launch details here; +} diff --git a/public/pages/workflow_detail/launches/launch_list.tsx b/public/pages/workflow_detail/launches/launch_list.tsx new file mode 100644 index 00000000..451e0591 --- /dev/null +++ b/public/pages/workflow_detail/launches/launch_list.tsx @@ -0,0 +1,119 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect } from 'react'; +import { debounce } from 'lodash'; +import { + EuiInMemoryTable, + Direction, + EuiFlexGroup, + EuiFlexItem, + EuiFieldSearch, + EuiFilterSelectItem, +} from '@elastic/eui'; +import { WORKFLOW_STATE, WorkflowLaunch } from '../../../../common'; +import { columns } from './columns'; +import { MultiSelectFilter } from '../../../general_components'; +import { getStateOptions } from '../../../utils'; + +interface LaunchListProps {} + +/** + * The searchable list of launches for this particular workflow. + */ +export function LaunchList(props: LaunchListProps) { + // TODO: finalize how we persist launches for a particular workflow. + // We may just add UI metadata tags to group workflows under a single, overall "workflow" + // const { workflows } = useSelector((state: AppState) => state.workflows); + const workflowLaunches = [ + { + id: 'Launch_1', + state: WORKFLOW_STATE.IN_PROGRESS, + lastUpdated: 12345678, + }, + { + id: 'Launch_2', + state: WORKFLOW_STATE.FAILED, + lastUpdated: 12345677, + }, + ] as WorkflowLaunch[]; + + // search bar state + const [searchQuery, setSearchQuery] = useState(''); + const debounceSearchQuery = debounce((query: string) => { + setSearchQuery(query); + }, 100); + + // filters state + const [selectedStates, setSelectedStates] = useState( + getStateOptions() + ); + const [filteredLaunches, setFilteredLaunches] = useState( + workflowLaunches + ); + + // When a filter selection or search query changes, update the filtered launches + useEffect(() => { + setFilteredLaunches( + fetchFilteredLaunches(workflowLaunches, selectedStates, searchQuery) + ); + }, [selectedStates, searchQuery]); + + const sorting = { + sort: { + field: 'id', + direction: 'asc' as Direction, + }, + }; + + return ( + + + + + debounceSearchQuery(e.target.value)} + /> + + + + + + + items={filteredLaunches} + rowHeader="id" + columns={columns} + sorting={sorting} + pagination={true} + message={'No existing launches found'} + /> + + + ); +} + +// Collect the final launch list after applying all filters +function fetchFilteredLaunches( + allLaunches: WorkflowLaunch[], + stateFilters: EuiFilterSelectItem[], + searchQuery: string +): WorkflowLaunch[] { + // @ts-ignore + const stateFilterStrings = stateFilters.map((filter) => filter.name); + const filteredLaunches = allLaunches.filter((launch) => + stateFilterStrings.includes(launch.state) + ); + return searchQuery.length === 0 + ? filteredLaunches + : filteredLaunches.filter((launch) => + launch.id.toLowerCase().includes(searchQuery.toLowerCase()) + ); +} diff --git a/public/pages/workflow_detail/launches/launches.tsx b/public/pages/workflow_detail/launches/launches.tsx new file mode 100644 index 00000000..b24ab016 --- /dev/null +++ b/public/pages/workflow_detail/launches/launches.tsx @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPageContent, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { Workflow } from '../../../../common'; +import { LaunchList } from './launch_list'; +import { LaunchDetails } from './launch_details'; + +interface LaunchesProps { + workflow?: Workflow; +} + +/** + * The launches page to browse launch history and view individual launch details. + */ +export function Launches(props: LaunchesProps) { + return ( + + +

Launches

+
+ + + + + + + + + +
+ ); +} diff --git a/public/pages/workflow_detail/prototype/index.ts b/public/pages/workflow_detail/prototype/index.ts new file mode 100644 index 00000000..6b2a3ec5 --- /dev/null +++ b/public/pages/workflow_detail/prototype/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { Prototype } from './prototype'; diff --git a/public/pages/workflow_detail/prototype/prototype.tsx b/public/pages/workflow_detail/prototype/prototype.tsx new file mode 100644 index 00000000..ede48acd --- /dev/null +++ b/public/pages/workflow_detail/prototype/prototype.tsx @@ -0,0 +1,37 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiFlexItem, + EuiPageContent, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { Workflow } from '../../../../common'; + +interface PrototypeProps { + workflow?: Workflow; +} + +/** + * The prototype page. Dedicated for testing out a launched workflow. + * Will have default simple interfaces for common application types, such as + * conversational chatbots. + */ +export function Prototype(props: PrototypeProps) { + return ( + + +

Prototype

+
+ + + TODO: add prototype page + +
+ ); +} diff --git a/public/pages/workflow_detail/workflow_detail.tsx b/public/pages/workflow_detail/workflow_detail.tsx index 2d33881e..f22bf2b3 100644 --- a/public/pages/workflow_detail/workflow_detail.tsx +++ b/public/pages/workflow_detail/workflow_detail.tsx @@ -3,16 +3,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useEffect } from 'react'; -import { RouteComponentProps } from 'react-router-dom'; +import React, { useEffect, useState } from 'react'; +import { RouteComponentProps, useLocation } from 'react-router-dom'; import { 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 { ResizableWorkspace } from './workspace'; +import { Launches } from './launches'; +import { Prototype } from './prototype'; export interface WorkflowDetailRouterProps { workflowId: string; @@ -21,6 +24,23 @@ export interface WorkflowDetailRouterProps { interface WorkflowDetailProps extends RouteComponentProps {} +enum WORKFLOW_DETAILS_TAB { + EDITOR = 'editor', + LAUNCHES = 'launches', + PROTOTYPE = 'prototype', +} + +const ACTIVE_TAB_PARAM = 'tab'; + +function replaceActiveTab(activeTab: string, props: WorkflowDetailProps) { + props.history.replace({ + ...history, + search: queryString.stringify({ + [ACTIVE_TAB_PARAM]: activeTab, + }), + }); +} + /** * The workflow details page. This is where users will configure, create, and * test their created workflows. Additionally, can be used to load existing workflows @@ -34,6 +54,24 @@ export function WorkflowDetail(props: WorkflowDetailProps) { ); const workflowName = workflow ? workflow.name : ''; + const tabFromUrl = queryString.parse(useLocation().search)[ + ACTIVE_TAB_PARAM + ] as WORKFLOW_DETAILS_TAB; + const [selectedTabId, setSelectedTabId] = useState( + tabFromUrl + ); + + // Default to editor tab if there is none or invalid tab ID specified via url. + useEffect(() => { + if ( + !selectedTabId || + !Object.values(WORKFLOW_DETAILS_TAB).includes(selectedTabId) + ) { + setSelectedTabId(WORKFLOW_DETAILS_TAB.EDITOR); + replaceActiveTab(WORKFLOW_DETAILS_TAB.EDITOR, props); + } + }, []); + useEffect(() => { getCore().chrome.setBreadcrumbs([ BREADCRUMBS.AI_APPLICATION_BUILDER, @@ -42,12 +80,48 @@ export function WorkflowDetail(props: WorkflowDetailProps) { ]); }); + const tabs = [ + { + id: WORKFLOW_DETAILS_TAB.EDITOR, + label: 'Editor', + isSelected: selectedTabId === WORKFLOW_DETAILS_TAB.EDITOR, + onClick: () => { + setSelectedTabId(WORKFLOW_DETAILS_TAB.EDITOR); + replaceActiveTab(WORKFLOW_DETAILS_TAB.EDITOR, props); + }, + }, + { + id: WORKFLOW_DETAILS_TAB.LAUNCHES, + label: 'Launches', + isSelected: selectedTabId === WORKFLOW_DETAILS_TAB.LAUNCHES, + onClick: () => { + setSelectedTabId(WORKFLOW_DETAILS_TAB.LAUNCHES); + replaceActiveTab(WORKFLOW_DETAILS_TAB.LAUNCHES, props); + }, + }, + { + id: WORKFLOW_DETAILS_TAB.PROTOTYPE, + label: 'Prototype', + isSelected: selectedTabId === WORKFLOW_DETAILS_TAB.PROTOTYPE, + onClick: () => { + setSelectedTabId(WORKFLOW_DETAILS_TAB.PROTOTYPE); + replaceActiveTab(WORKFLOW_DETAILS_TAB.PROTOTYPE, props); + }, + }, + ]; + return ( - - + + {selectedTabId === WORKFLOW_DETAILS_TAB.EDITOR && ( + + )} + {selectedTabId === WORKFLOW_DETAILS_TAB.LAUNCHES && } + {selectedTabId === WORKFLOW_DETAILS_TAB.PROTOTYPE && ( + + )} diff --git a/public/pages/workflow_detail/workspace/resizable_workspace.tsx b/public/pages/workflow_detail/workspace/resizable_workspace.tsx index 36d2f225..983e36f0 100644 --- a/public/pages/workflow_detail/workspace/resizable_workspace.tsx +++ b/public/pages/workflow_detail/workspace/resizable_workspace.tsx @@ -141,7 +141,7 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
{(EuiResizablePanel, EuiResizableButton, { togglePanel }) => { if (togglePanel) { @@ -151,7 +151,12 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { return ( <> - + onToggleChange()} > diff --git a/public/pages/workflows/workflow_list/columns.tsx b/public/pages/workflows/workflow_list/columns.tsx index 07300ca0..88aa7daf 100644 --- a/public/pages/workflows/workflow_list/columns.tsx +++ b/public/pages/workflows/workflow_list/columns.tsx @@ -26,4 +26,9 @@ export const columns = [ name: 'Description', sortable: false, }, + { + field: 'state', + name: 'Status', + sortable: true, + }, ]; diff --git a/public/pages/workflows/workflow_list/workflow_list.tsx b/public/pages/workflows/workflow_list/workflow_list.tsx index 005eb6e3..8c52a9bc 100644 --- a/public/pages/workflows/workflow_list/workflow_list.tsx +++ b/public/pages/workflows/workflow_list/workflow_list.tsx @@ -3,36 +3,105 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { useSelector } from 'react-redux'; -import { EuiInMemoryTable, Direction } from '@elastic/eui'; +import { debounce } from 'lodash'; +import { + EuiInMemoryTable, + Direction, + EuiFlexGroup, + EuiFlexItem, + EuiFilterSelectItem, + EuiFieldSearch, +} from '@elastic/eui'; import { AppState } from '../../../store'; import { Workflow } from '../../../../common'; import { columns } from './columns'; +import { MultiSelectFilter } from '../../../general_components'; +import { getStateOptions } from '../../../utils'; interface WorkflowListProps {} +const sorting = { + sort: { + field: 'name', + direction: 'asc' as Direction, + }, +}; + /** * The searchable list of created workflows. */ export function WorkflowList(props: WorkflowListProps) { const { workflows } = useSelector((state: AppState) => state.workflows); - const sorting = { - sort: { - field: 'name', - direction: 'asc' as Direction, - }, - }; + // search bar state + const [searchQuery, setSearchQuery] = useState(''); + const debounceSearchQuery = debounce((query: string) => { + setSearchQuery(query); + }, 100); + + // filters state + const [selectedStates, setSelectedStates] = useState( + getStateOptions() + ); + const [filteredWorkflows, setFilteredWorkflows] = useState( + workflows || [] + ); + + // When a filter selection or search query changes, update the list + useEffect(() => { + setFilteredWorkflows( + fetchFilteredWorkflows(workflows, selectedStates, searchQuery) + ); + }, [selectedStates, searchQuery]); return ( - - items={workflows} - rowHeader="name" - columns={columns} - sorting={sorting} - pagination={true} - message={'No existing workflows found'} - /> + + + + + debounceSearchQuery(e.target.value)} + /> + + + + + + + items={filteredWorkflows} + rowHeader="name" + columns={columns} + sorting={sorting} + pagination={true} + message={'No existing workflows found'} + /> + + + ); +} + +// Collect the final workflow list after applying all filters +function fetchFilteredWorkflows( + allWorkflows: Workflow[], + stateFilters: EuiFilterSelectItem[], + searchQuery: string +): Workflow[] { + // @ts-ignore + const stateFilterStrings = stateFilters.map((filter) => filter.name); + const filteredWorkflows = allWorkflows.filter((workflow) => + stateFilterStrings.includes(workflow.state) ); + return searchQuery.length === 0 + ? filteredWorkflows + : filteredWorkflows.filter((workflow) => + workflow.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); } diff --git a/public/pages/workflows/workflows.tsx b/public/pages/workflows/workflows.tsx index 0b3c4649..04ae3dc2 100644 --- a/public/pages/workflows/workflows.tsx +++ b/public/pages/workflows/workflows.tsx @@ -30,7 +30,7 @@ enum WORKFLOWS_TAB { CREATE = 'create', } -const ACTIVE_TAB_PARAM = 'active_tab'; +const ACTIVE_TAB_PARAM = 'tab'; function replaceActiveTab(activeTab: string, props: WorkflowsProps) { props.history.replace({ @@ -54,10 +54,13 @@ export function Workflows(props: WorkflowsProps) { ] as WORKFLOWS_TAB; const [selectedTabId, setSelectedTabId] = useState(tabFromUrl); - // If there is no selected tab, default to a tab depending on if user - // has existing created workflows or not. + // If there is no selected tab or invalid tab, default to a tab depending + // on if user has existing created workflows or not. useEffect(() => { - if (!selectedTabId) { + if ( + !selectedTabId || + !Object.values(WORKFLOWS_TAB).includes(selectedTabId) + ) { if (workflows?.length > 0) { setSelectedTabId(WORKFLOWS_TAB.MANAGE); replaceActiveTab(WORKFLOWS_TAB.MANAGE, props); @@ -100,6 +103,7 @@ export function Workflows(props: WorkflowsProps) { }, }, ]} + bottomBorder={true} /> diff --git a/public/store/reducers/workflows_reducer.ts b/public/store/reducers/workflows_reducer.ts index 20c60f76..ca10aed7 100644 --- a/public/store/reducers/workflows_reducer.ts +++ b/public/store/reducers/workflows_reducer.ts @@ -12,6 +12,7 @@ import { TextEmbeddingProcessor, generateId, initComponentData, + WORKFLOW_STATE, } from '../../../common'; // TODO: remove after fetching from server-side @@ -46,6 +47,7 @@ const initialState = { name: 'Workflow-1', id: 'workflow-1-id', description: 'description for workflow 1', + state: WORKFLOW_STATE.SUCCEEDED, workspaceFlowState: { nodes: dummyNodes, edges: [] as ReactFlowEdge[], @@ -56,6 +58,7 @@ const initialState = { name: 'Workflow-2', id: 'workflow-2-id', description: 'description for workflow 2', + state: WORKFLOW_STATE.FAILED, workspaceFlowState: { nodes: dummyNodes, edges: [] as ReactFlowEdge[], diff --git a/public/utils/utils.ts b/public/utils/utils.ts index 4892442d..a36be3d5 100644 --- a/public/utils/utils.ts +++ b/public/utils/utils.ts @@ -4,6 +4,7 @@ */ import { FormikErrors, FormikTouched, FormikValues } from 'formik'; +import { EuiFilterSelectItem } from '@elastic/eui'; import { Schema, ObjectSchema } from 'yup'; import * as yup from 'yup'; import { @@ -13,6 +14,7 @@ import { IComponentData, IComponentField, WorkspaceFormValues, + WORKFLOW_STATE, } from '../../common'; // Append 16 random characters @@ -124,3 +126,28 @@ function getFieldSchema(field: IComponentField): Schema { ? baseSchema.optional() : baseSchema.required('Required'); } + +export function getStateOptions(): EuiFilterSelectItem[] { + return [ + // @ts-ignore + { + name: WORKFLOW_STATE.SUCCEEDED, + checked: 'on', + } as EuiFilterSelectItem, + // @ts-ignore + { + name: WORKFLOW_STATE.NOT_STARTED, + checked: 'on', + } as EuiFilterSelectItem, + // @ts-ignore + { + name: WORKFLOW_STATE.IN_PROGRESS, + checked: 'on', + } as EuiFilterSelectItem, + // @ts-ignore + { + name: WORKFLOW_STATE.FAILED, + checked: 'on', + } as EuiFilterSelectItem, + ]; +}