From a9892193a58b63f84d0a4f3a0926a0cc82898679 Mon Sep 17 00:00:00 2001 From: Tyler Ohlsen Date: Tue, 3 Oct 2023 12:07:33 -0700 Subject: [PATCH] Clean up obj serialization; use setDirty(); Signed-off-by: Tyler Ohlsen --- common/interfaces.ts | 17 ++-- public/component_types/base_component.tsx | 15 +++ public/component_types/indices/knn_index.ts | 4 +- .../processors/text_embedding_processor.ts | 6 +- .../workflow_detail/components/header.tsx | 19 +++- public/pages/workflow_detail/utils/index.ts | 6 ++ public/pages/workflow_detail/utils/utils.ts | 92 +++++++++++++++++++ .../workflow_detail/workspace/workspace.tsx | 12 ++- public/store/reducers/workflows_reducer.ts | 10 +- public/store/reducers/workspace_reducer.ts | 17 +--- 10 files changed, 163 insertions(+), 35 deletions(-) create mode 100644 public/component_types/base_component.tsx create mode 100644 public/pages/workflow_detail/utils/index.ts create mode 100644 public/pages/workflow_detail/utils/utils.ts diff --git a/common/interfaces.ts b/common/interfaces.ts index 5487c718..783c8ae7 100644 --- a/common/interfaces.ts +++ b/common/interfaces.ts @@ -28,7 +28,7 @@ type ReactFlowViewport = { zoom: number; }; -export type ReactFlowState = { +export type WorkspaceFlowState = { nodes: ReactFlowComponent[]; edges: ReactFlowEdge[]; viewport?: ReactFlowViewport; @@ -38,23 +38,23 @@ export type ReactFlowState = { ********** USE CASE TEMPLATE TYPES/INTERFACES ********** */ -type TemplateNode = { +export type TemplateNode = { id: string; inputs: {}; }; -type TemplateEdge = { +export type TemplateEdge = { source: string; target: string; }; -type TemplateFlow = { +export type TemplateFlow = { userParams: {}; nodes: TemplateNode[]; edges: TemplateEdge[]; }; -type TemplateFlows = { +export type TemplateFlows = { provision: TemplateFlow; ingest: TemplateFlow; query: TemplateFlow; @@ -73,7 +73,12 @@ export type Workflow = { name: string; description?: string; // ReactFlow state may not exist if a workflow is created via API/backend-only. - reactFlowState?: ReactFlowState; + workspaceFlowState?: WorkspaceFlowState; template: UseCaseTemplate; lastUpdated: number; }; + +export enum USE_CASE { + SEMANTIC_SEARCH = 'semantic_search', + CUSTOM = 'custom', +} diff --git a/public/component_types/base_component.tsx b/public/component_types/base_component.tsx new file mode 100644 index 00000000..1e6dcd4f --- /dev/null +++ b/public/component_types/base_component.tsx @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A base component class. + */ +export abstract class BaseComponent { + // Persist a standard toObj() fn that all component classes can use. This is necessary + // so we have standard JS Object when serializing comoponent state in redux. + toObj() { + return Object.assign({}, this); + } +} diff --git a/public/component_types/indices/knn_index.ts b/public/component_types/indices/knn_index.ts index 9255b47d..b66e17c0 100644 --- a/public/component_types/indices/knn_index.ts +++ b/public/component_types/indices/knn_index.ts @@ -4,6 +4,7 @@ */ import { COMPONENT_CATEGORY, COMPONENT_CLASS } from '../../utils'; +import { BaseComponent } from '../base_component'; import { IComponent, IComponentField, @@ -15,7 +16,7 @@ import { /** * A k-NN index UI component */ -export class KnnIndex implements IComponent { +export class KnnIndex extends BaseComponent implements IComponent { type: COMPONENT_CLASS; label: string; description: string; @@ -30,6 +31,7 @@ export class KnnIndex implements IComponent { outputs: IComponentOutput[]; constructor() { + super(); this.type = COMPONENT_CLASS.KNN_INDEX; this.label = 'k-NN Index'; this.description = 'A k-NN Index to be used as a vector store'; diff --git a/public/component_types/processors/text_embedding_processor.ts b/public/component_types/processors/text_embedding_processor.ts index 2c3fa078..c6f3961a 100644 --- a/public/component_types/processors/text_embedding_processor.ts +++ b/public/component_types/processors/text_embedding_processor.ts @@ -4,6 +4,7 @@ */ import { COMPONENT_CATEGORY, COMPONENT_CLASS } from '../../utils'; +import { BaseComponent } from '../base_component'; import { IComponent, IComponentField, @@ -15,7 +16,9 @@ import { /** * A text embedding processor UI component */ -export class TextEmbeddingProcessor implements IComponent { +export class TextEmbeddingProcessor + extends BaseComponent + implements IComponent { type: COMPONENT_CLASS; label: string; description: string; @@ -29,6 +32,7 @@ export class TextEmbeddingProcessor implements IComponent { outputs: IComponentOutput[]; constructor() { + super(); this.type = COMPONENT_CLASS.TEXT_EMBEDDING_PROCESSOR; this.label = 'Text Embedding Processor'; this.description = diff --git a/public/pages/workflow_detail/components/header.tsx b/public/pages/workflow_detail/components/header.tsx index fdcccf66..6539952e 100644 --- a/public/pages/workflow_detail/components/header.tsx +++ b/public/pages/workflow_detail/components/header.tsx @@ -3,15 +3,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useContext } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { EuiPageHeader, EuiButton } from '@elastic/eui'; import { Workflow } from '../../../../common'; +import { saveWorkflow } from '../utils'; +import { rfContext, AppState, removeDirty } from '../../../store'; interface WorkflowDetailHeaderProps { workflow?: Workflow; } export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) { + const dispatch = useDispatch(); + const { reactFlowInstance } = useContext(rfContext); + const isDirty = useSelector((state: AppState) => state.workspace.isDirty); + return ( {}}> Prototype , - {}}> + { + // @ts-ignore + saveWorkflow(props.workflow, reactFlowInstance); + dispatch(removeDirty()); + }} + > Save , ]} diff --git a/public/pages/workflow_detail/utils/index.ts b/public/pages/workflow_detail/utils/index.ts new file mode 100644 index 00000000..079132ce --- /dev/null +++ b/public/pages/workflow_detail/utils/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './utils'; diff --git a/public/pages/workflow_detail/utils/utils.ts b/public/pages/workflow_detail/utils/utils.ts new file mode 100644 index 00000000..d1aa76cc --- /dev/null +++ b/public/pages/workflow_detail/utils/utils.ts @@ -0,0 +1,92 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + WorkspaceFlowState, + UseCaseTemplate, + Workflow, + USE_CASE, + ReactFlowComponent, +} from '../../../../common'; + +export function saveWorkflow(workflow: Workflow, rfInstance: any): void { + let curFlowState = rfInstance.toObject(); + + curFlowState = { + ...curFlowState, + nodes: processNodes(curFlowState.nodes), + }; + + const isValid = validateFlowState(curFlowState); + if (isValid) { + const updatedWorkflow = { + ...workflow, + workspaceFlowState: curFlowState, + template: generateUseCaseTemplate(curFlowState), + } as Workflow; + if (workflow.id) { + // TODO: implement connection to update workflow API + } else { + // TODO: implement connection to create workflow API + } + } else { + return; + } +} + +// TODO: implement this. Need more info on UX side to finalize what we need +// to persist, what validation to do, etc. +// Note we don't have to validate connections since that is done via input/output handlers. +function validateFlowState(flowState: WorkspaceFlowState): boolean { + return true; +} + +// TODO: implement this +function generateUseCaseTemplate( + flowState: WorkspaceFlowState +): UseCaseTemplate { + return { + name: 'example-name', + description: 'example description', + type: USE_CASE.SEMANTIC_SEARCH, + userInputs: {}, + workflows: { + provision: { + userParams: {}, + nodes: [], + edges: [], + }, + ingest: { + userParams: {}, + nodes: [], + edges: [], + }, + query: { + userParams: {}, + nodes: [], + edges: [], + }, + }, + } as UseCaseTemplate; +} + +// Process the raw ReactFlow nodes to only persist the fields we need +function processNodes(nodes: ReactFlowComponent[]): ReactFlowComponent[] { + return nodes + .map((node: ReactFlowComponent) => { + return Object.fromEntries( + ['id', 'data', 'type', 'width', 'height'].map((key: string) => [ + key, + node[key], + ]) + ) as ReactFlowComponent; + }) + .map((node: ReactFlowComponent) => { + return { + ...node, + selected: false, + }; + }); +} diff --git a/public/pages/workflow_detail/workspace/workspace.tsx b/public/pages/workflow_detail/workspace/workspace.tsx index 5ca1ed73..515e2503 100644 --- a/public/pages/workflow_detail/workspace/workspace.tsx +++ b/public/pages/workflow_detail/workspace/workspace.tsx @@ -4,6 +4,7 @@ */ import React, { useRef, useContext, useCallback, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; import ReactFlow, { Controls, Background, @@ -12,7 +13,7 @@ import ReactFlow, { addEdge, } from 'reactflow'; import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; -import { rfContext } from '../../../store'; +import { rfContext, setDirty } from '../../../store'; import { IComponent, Workflow } from '../../../../common'; import { generateId } from '../../../utils'; import { getCore } from '../../../services'; @@ -32,6 +33,7 @@ const nodeTypes = { customComponent: WorkspaceComponent }; const edgeTypes = { customEdge: DeletableEdge }; export function Workspace(props: WorkspaceProps) { + const dispatch = useDispatch(); const reactFlowWrapper = useRef(null); const { reactFlowInstance, setReactFlowInstance } = useContext(rfContext); @@ -45,6 +47,7 @@ export function Workspace(props: WorkspaceProps) { type: 'customEdge', }; setEdges((eds) => addEdge(edge, eds)); + dispatch(setDirty()); }, [setEdges] ); @@ -90,6 +93,7 @@ export function Workspace(props: WorkspaceProps) { }; setNodes((nds) => nds.concat(newNode)); + dispatch(setDirty()); }, [reactFlowInstance] ); @@ -99,9 +103,9 @@ export function Workspace(props: WorkspaceProps) { useEffect(() => { const workflow = props.workflow; if (workflow) { - if (workflow.reactFlowState) { - setNodes(workflow.reactFlowState.nodes); - setEdges(workflow.reactFlowState.edges); + if (workflow.workspaceFlowState) { + setNodes(workflow.workspaceFlowState.nodes); + setEdges(workflow.workspaceFlowState.edges); } else { getCore().notifications.toasts.addWarning( `There is no configured UI flow for workflow: ${workflow.name}` diff --git a/public/store/reducers/workflows_reducer.ts b/public/store/reducers/workflows_reducer.ts index 77d43b94..efc07a07 100644 --- a/public/store/reducers/workflows_reducer.ts +++ b/public/store/reducers/workflows_reducer.ts @@ -18,19 +18,19 @@ const dummyNodes = [ { id: generateId('text_embedding_processor'), position: { x: 0, y: 500 }, - data: new TextEmbeddingProcessor(), + data: new TextEmbeddingProcessor().toObj(), type: 'customComponent', }, { id: generateId('text_embedding_processor'), position: { x: 0, y: 200 }, - data: new TextEmbeddingProcessor(), + data: new TextEmbeddingProcessor().toObj(), type: 'customComponent', }, { id: generateId('knn_index'), position: { x: 500, y: 500 }, - data: new KnnIndex(), + data: new KnnIndex().toObj(), type: 'customComponent', }, ] as ReactFlowComponent[]; @@ -42,7 +42,7 @@ const initialState = { name: 'Workflow-1', id: 'workflow-1-id', description: 'description for workflow 1', - reactFlowState: { + workspaceFlowState: { nodes: dummyNodes, edges: [] as ReactFlowEdge[], }, @@ -52,7 +52,7 @@ const initialState = { name: 'Workflow-2', id: 'workflow-2-id', description: 'description for workflow 2', - reactFlowState: { + workspaceFlowState: { nodes: dummyNodes, edges: [] as ReactFlowEdge[], }, diff --git a/public/store/reducers/workspace_reducer.ts b/public/store/reducers/workspace_reducer.ts index 2eb6748e..7bfbd6dc 100644 --- a/public/store/reducers/workspace_reducer.ts +++ b/public/store/reducers/workspace_reducer.ts @@ -4,20 +4,9 @@ */ import { createSlice } from '@reduxjs/toolkit'; -import { IComponent } from '../../../common'; -import { KnnIndex, TextEmbeddingProcessor } from '../../component_types'; - -// TODO: should be fetched from server-side. This will be the list of all -// available components that the framework offers. This will be used in the component -// library to populate the available components to drag-and-drop into the workspace. -const dummyComponents = [ - new TextEmbeddingProcessor(), - new KnnIndex(), -] as IComponent[]; const initialState = { isDirty: false, - components: dummyComponents, }; const workspaceSlice = createSlice({ @@ -30,12 +19,8 @@ const workspaceSlice = createSlice({ removeDirty(state) { state.isDirty = false; }, - setComponents(state, action) { - state.components = action.payload; - state.isDirty = true; - }, }, }); export const workspaceReducer = workspaceSlice.reducer; -export const { setDirty, removeDirty, setComponents } = workspaceSlice.actions; +export const { setDirty, removeDirty } = workspaceSlice.actions;