diff --git a/common/constants.ts b/common/constants.ts index f82a1167..a7f442cc 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { WORKFLOW_STATE } from './interfaces'; + export const PLUGIN_ID = 'flow-framework'; /** @@ -35,6 +37,8 @@ export const GET_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}`; export const SEARCH_WORKFLOWS_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/search`; 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 PROVISION_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/provision`; +export const DEPROVISION_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/deprovision`; export const DELETE_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/delete`; export const GET_PRESET_WORKFLOWS_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/presets`; @@ -49,5 +53,13 @@ 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'; export const DEFAULT_NEW_WORKFLOW_DESCRIPTION = 'My new workflow'; +export const DEFAULT_NEW_WORKFLOW_STATE = WORKFLOW_STATE.NOT_STARTED; +export const DEFAULT_NEW_WORKFLOW_STATE_TYPE = ('NOT_STARTED' as any) as typeof WORKFLOW_STATE; export const DATE_FORMAT_PATTERN = 'MM/DD/YY hh:mm A'; export const EMPTY_FIELD_STRING = '--'; +export const FETCH_ALL_QUERY_BODY = { + query: { + match_all: {}, + }, + size: 1000, +}; diff --git a/public/pages/workflow_detail/component_details/component_details.tsx b/public/pages/workflow_detail/component_details/component_details.tsx index 7ecaed6e..242ae81d 100644 --- a/public/pages/workflow_detail/component_details/component_details.tsx +++ b/public/pages/workflow_detail/component_details/component_details.tsx @@ -4,7 +4,7 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import { EuiPanel } from '@elastic/eui'; import { ReactFlowComponent } from '../../../../common'; import { ComponentInputs } from './component_inputs'; import { EmptyComponentInputs } from './empty_component_inputs'; @@ -24,23 +24,15 @@ interface ComponentDetailsProps { */ export function ComponentDetails(props: ComponentDetailsProps) { return ( - - - - {props.selectedComponent ? ( - - ) : ( - - )} - - - + + {props.selectedComponent ? ( + + ) : ( + + )} + ); } diff --git a/public/pages/workflow_detail/components/header.tsx b/public/pages/workflow_detail/components/header.tsx index c3c2e967..b31dab11 100644 --- a/public/pages/workflow_detail/components/header.tsx +++ b/public/pages/workflow_detail/components/header.tsx @@ -4,8 +4,19 @@ */ import React from 'react'; -import { EuiPageHeader, EuiButton, EuiLoadingSpinner } from '@elastic/eui'; -import { DEFAULT_NEW_WORKFLOW_NAME, Workflow } from '../../../../common'; +import { + EuiPageHeader, + EuiButton, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiText, +} from '@elastic/eui'; +import { + DEFAULT_NEW_WORKFLOW_NAME, + DEFAULT_NEW_WORKFLOW_STATE, + Workflow, +} from '../../../../common'; interface WorkflowDetailHeaderProps { tabs: any[]; @@ -14,20 +25,42 @@ interface WorkflowDetailHeaderProps { } export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) { + function getTitle() { + return props.workflow ? ( + props.workflow.name + ) : props.isNewWorkflow && !props.workflow ? ( + DEFAULT_NEW_WORKFLOW_NAME + ) : ( + + ); + } + + function getState() { + return props.workflow?.state + ? props.workflow.state + : props.isNewWorkflow + ? DEFAULT_NEW_WORKFLOW_STATE + : null; + } + return ( - ) + + {getTitle()} + + {getState()} + + } rightSideItems={[ // TODO: finalize if this is needed - {}}> + {}} + > Delete , ]} diff --git a/public/pages/workflow_detail/workflow-detail-styles.scss b/public/pages/workflow_detail/workflow-detail-styles.scss new file mode 100644 index 00000000..1d1194d8 --- /dev/null +++ b/public/pages/workflow_detail/workflow-detail-styles.scss @@ -0,0 +1,3 @@ +.workflow-detail { + overflow: hidden; +} diff --git a/public/pages/workflow_detail/workflow_detail.tsx b/public/pages/workflow_detail/workflow_detail.tsx index 43664bf5..a2f15dd8 100644 --- a/public/pages/workflow_detail/workflow_detail.tsx +++ b/public/pages/workflow_detail/workflow_detail.tsx @@ -5,22 +5,31 @@ import React, { useEffect, useState } from 'react'; import { RouteComponentProps, useLocation } from 'react-router-dom'; -import { useDispatch, useSelector } from 'react-redux'; +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, searchModels, searchWorkflows } from '../../store'; +import { + AppState, + searchModels, + searchWorkflows, + useAppDispatch, +} from '../../store'; import { ResizableWorkspace } from './workspace'; import { Launches } from './launches'; import { Prototype } from './prototype'; import { DEFAULT_NEW_WORKFLOW_NAME, + FETCH_ALL_QUERY_BODY, NEW_WORKFLOW_ID_URL, } from '../../../common'; +// styling +import './workflow-detail-styles.scss'; + export interface WorkflowDetailRouterProps { workflowId: string; } @@ -53,7 +62,7 @@ function replaceActiveTab(activeTab: string, props: WorkflowDetailProps) { */ export function WorkflowDetail(props: WorkflowDetailProps) { - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const { workflows, cachedWorkflow } = useSelector( (state: AppState) => state.workflows ); @@ -101,9 +110,9 @@ export function WorkflowDetail(props: WorkflowDetailProps) { useEffect(() => { if (!isNewWorkflow) { // TODO: can optimize to only fetch a single workflow - dispatch(searchWorkflows({ query: { match_all: {} } })); + dispatch(searchWorkflows(FETCH_ALL_QUERY_BODY)); } - dispatch(searchModels({ query: { match_all: {} } })); + dispatch(searchModels(FETCH_ALL_QUERY_BODY)); }, []); const tabs = [ @@ -139,7 +148,7 @@ export function WorkflowDetail(props: WorkflowDetailProps) { return ( - + state.workspace.isDirty); + const { isDirty } = useSelector((state: AppState) => state.workspace); + const { loading } = useSelector((state: AppState) => state.workflows); const [isFirstSave, setIsFirstSave] = useState(props.isNewWorkflow); - const isSaveable = isFirstSave ? true : isDirty; // Workflow state const [workflow, setWorkflow] = useState( @@ -95,6 +102,26 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { ReactFlowComponent >(); + // Save/provision/deprovision button state + const isSaveable = isFirstSave ? true : isDirty; + const isProvisionable = + !isDirty && + !props.isNewWorkflow && + formValidOnSubmit && + flowValidOnSubmit && + props.workflow?.state === WORKFLOW_STATE.NOT_STARTED; + const isDeprovisionable = + !props.isNewWorkflow && + props.workflow?.state !== WORKFLOW_STATE.NOT_STARTED; + + // Loading state + const [isProvisioning, setIsProvisioning] = useState(false); + const [isDeprovisioning, setIsDeprovisioning] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const isCreating = isSaving && props.isNewWorkflow; + const isLoadingGlobal = + loading || isProvisioning || isDeprovisioning || isSaving || isCreating; + /** * Custom listener on when nodes are selected / de-selected. Passed to * downstream ReactFlow components you can listen using @@ -214,6 +241,47 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { } } + // Utility validation fn used before executing any API calls (save, provision) + function validateFormAndFlow( + formikProps: FormikProps, + processWorkflowFn: (workflow: Workflow) => void + ): void { + // Submit the form to bubble up any errors. + // Ideally we handle Promise accept/rejects with submitForm(), but there is + // open issues for that - see https://github.com/jaredpalmer/formik/issues/2057 + // The workaround is to additionally execute validateForm() which will return any errors found. + formikProps.submitForm(); + formikProps.validateForm().then((validationResults: {}) => { + if (Object.keys(validationResults).length > 0) { + setFormValidOnSubmit(false); + setIsSaving(false); + } else { + setFormValidOnSubmit(true); + let curFlowState = reactFlowInstance.toObject() as WorkspaceFlowState; + curFlowState = { + ...curFlowState, + nodes: processNodes(curFlowState.nodes), + }; + if (validateWorkspaceFlow(curFlowState)) { + setFlowValidOnSubmit(true); + const updatedWorkflow = { + ...workflow, + ui_metadata: { + ...workflow?.ui_metadata, + workspaceFlow: curFlowState, + }, + workflows: toTemplateFlows(curFlowState, formikProps.values), + } as Workflow; + processWorkflowFn(updatedWorkflow); + } else { + // TODO: bubble up flow error? + setFlowValidOnSubmit(false); + setIsSaving(false); + } + } + }); + } + return ( {}}> - Launch + { + if (workflow?.id) { + setIsDeprovisioning(true); + dispatch(deprovisionWorkflow(workflow.id)) + .unwrap() + .then(async (result) => { + await new Promise((f) => setTimeout(f, 3000)); + dispatch(getWorkflowState(workflow.id as string)); + setIsDeprovisioning(false); + }) + .catch((error: any) => { + // TODO: process error (toast msg?) + console.log('error: ', error); + setIsDeprovisioning(false); + }); + } else { + // TODO: this case should not happen + } + }} + > + Deprovision , { + if (workflow?.id) { + setIsProvisioning(true); + dispatch(provisionWorkflow(workflow.id)) + .unwrap() + .then(async (result) => { + await new Promise((f) => setTimeout(f, 3000)); + dispatch(getWorkflowState(workflow.id as string)); + setIsProvisioning(false); + }) + .catch((error: any) => { + // TODO: process error (toast msg?) + console.log('error: ', error); + setIsProvisioning(false); + }); + } else { + // TODO: this case should not happen + } + }} + > + Provision + , + { + setIsSaving(true); dispatch(removeDirty()); if (isFirstSave) { setIsFirstSave(false); } - // Submit the form to bubble up any errors. - // Ideally we handle Promise accept/rejects with submitForm(), but there is - // open issues for that - see https://github.com/jaredpalmer/formik/issues/2057 - // The workaround is to additionally execute validateForm() which will return any errors found. - formikProps.submitForm(); - formikProps.validateForm().then((validationResults: {}) => { - if (Object.keys(validationResults).length > 0) { - setFormValidOnSubmit(false); - } else { - setFormValidOnSubmit(true); - let curFlowState = reactFlowInstance.toObject() as WorkspaceFlowState; - curFlowState = { - ...curFlowState, - nodes: processNodes(curFlowState.nodes), - }; - if (validateWorkspaceFlow(curFlowState)) { - setFlowValidOnSubmit(true); - const updatedWorkflow = { - ...workflow, - ui_metadata: { - ...workflow?.ui_metadata, - workspaceFlow: curFlowState, - }, - workflows: toTemplateFlows( - curFlowState, - formikProps.values - ), - } as Workflow; - if (updatedWorkflow.id) { - // TODO: add update workflow API - } else { - dispatch(createWorkflow(updatedWorkflow)); - } + validateFormAndFlow( + formikProps, + // The callback fn to run if everything is valid. + (updatedWorkflow) => { + if (updatedWorkflow.id) { + // TODO: add update workflow API + // make sure to set isSaving to false in catch block } else { - setFlowValidOnSubmit(false); + dispatch(createWorkflow(updatedWorkflow)) + .unwrap() + .then((result) => { + const { workflow } = result; + history.replace(`/workflows/${workflow.id}`); + history.go(0); + }) + .catch((error: any) => { + // TODO: process error (toast msg?) + console.log('error: ', error); + setIsSaving(false); + }); } } - }); + ); }} > - Save + {props.isNewWorkflow || isCreating ? 'Create' : 'Save'} , ]} bottomBorder={false} @@ -343,7 +446,6 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { onToggleChange()} > - + + + + + ); diff --git a/public/pages/workflow_detail/workspace/workspace.tsx b/public/pages/workflow_detail/workspace/workspace.tsx index b5af1b0c..234e3518 100644 --- a/public/pages/workflow_detail/workspace/workspace.tsx +++ b/public/pages/workflow_detail/workspace/workspace.tsx @@ -4,7 +4,6 @@ */ import React, { useRef, useCallback, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; import ReactFlow, { Controls, Background, @@ -18,7 +17,7 @@ import ReactFlow, { MarkerType, } from 'reactflow'; import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; -import { setDirty } from '../../../store'; +import { setDirty, useAppDispatch } from '../../../store'; import { IComponentData, ReactFlowComponent, @@ -53,7 +52,7 @@ const nodeTypes = { const edgeTypes = { customEdge: DeletableEdge }; export function Workspace(props: WorkspaceProps) { - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); // ReactFlow state const reactFlowWrapper = useRef(null); diff --git a/public/pages/workflow_detail/workspace/workspace_components/new_or_existing_tabs.tsx b/public/pages/workflow_detail/workspace/workspace_components/new_or_existing_tabs.tsx index f5a936fc..777e36b4 100644 --- a/public/pages/workflow_detail/workspace/workspace_components/new_or_existing_tabs.tsx +++ b/public/pages/workflow_detail/workspace/workspace_components/new_or_existing_tabs.tsx @@ -30,7 +30,7 @@ const inputTabs = [ export function NewOrExistingTabs(props: NewOrExistingTabsProps) { return ( - + {inputTabs.map((tab, idx) => { return ( state.presets ); diff --git a/public/pages/workflows/workflow_list/workflow_list.tsx b/public/pages/workflows/workflow_list/workflow_list.tsx index f122a2e0..813f638f 100644 --- a/public/pages/workflows/workflow_list/workflow_list.tsx +++ b/public/pages/workflows/workflow_list/workflow_list.tsx @@ -4,7 +4,7 @@ */ import React, { useState, useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { debounce } from 'lodash'; import { EuiInMemoryTable, @@ -15,7 +15,7 @@ import { EuiFieldSearch, EuiLoadingSpinner, } from '@elastic/eui'; -import { AppState, deleteWorkflow } from '../../../store'; +import { AppState, deleteWorkflow, useAppDispatch } from '../../../store'; import { Workflow } from '../../../../common'; import { columns } from './columns'; import { @@ -37,7 +37,7 @@ const sorting = { * The searchable list of created workflows. */ export function WorkflowList(props: WorkflowListProps) { - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const { workflows, loading } = useSelector( (state: AppState) => state.workflows ); @@ -91,14 +91,14 @@ export function WorkflowList(props: WorkflowListProps) { return ( <> - {isDeleteModalOpen && workflowToDelete !== undefined && ( + {isDeleteModalOpen && workflowToDelete?.id !== undefined && ( { clearDeleteState(); }} onConfirm={() => { - dispatch(deleteWorkflow(workflowToDelete.id)); + dispatch(deleteWorkflow(workflowToDelete.id as string)); clearDeleteState(); }} /> diff --git a/public/pages/workflows/workflows.tsx b/public/pages/workflows/workflows.tsx index b745d888..fdc5acdb 100644 --- a/public/pages/workflows/workflows.tsx +++ b/public/pages/workflows/workflows.tsx @@ -14,13 +14,14 @@ import { EuiSpacer, } from '@elastic/eui'; import queryString from 'query-string'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { BREADCRUMBS } from '../../utils'; import { getCore } from '../../services'; import { WorkflowList } from './workflow_list'; import { NewWorkflow } from './new_workflow'; -import { AppState, searchWorkflows } from '../../store'; +import { AppState, searchWorkflows, useAppDispatch } from '../../store'; import { EmptyListMessage } from './empty_list_message'; +import { FETCH_ALL_QUERY_BODY } from '../../../common'; export interface WorkflowsRouterProps {} @@ -48,7 +49,7 @@ function replaceActiveTab(activeTab: string, props: WorkflowsProps) { * to get started on a new workflow. */ export function Workflows(props: WorkflowsProps) { - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const { workflows, loading } = useSelector( (state: AppState) => state.workflows ); @@ -72,7 +73,7 @@ export function Workflows(props: WorkflowsProps) { // If the user navigates back to the manage tab, re-fetch workflows useEffect(() => { if (selectedTabId === WORKFLOWS_TAB.MANAGE) { - dispatch(searchWorkflows({ query: { match_all: {} } })); + dispatch(searchWorkflows(FETCH_ALL_QUERY_BODY)); } }, [selectedTabId]); @@ -85,7 +86,7 @@ export function Workflows(props: WorkflowsProps) { // On initial render: fetch all workflows useEffect(() => { - dispatch(searchWorkflows({ query: { match_all: {} } })); + dispatch(searchWorkflows(FETCH_ALL_QUERY_BODY)); }, []); return ( diff --git a/public/route_service.ts b/public/route_service.ts index 78faddd9..20aaf202 100644 --- a/public/route_service.ts +++ b/public/route_service.ts @@ -13,6 +13,8 @@ import { SEARCH_WORKFLOWS_NODE_API_PATH, GET_PRESET_WORKFLOWS_NODE_API_PATH, SEARCH_MODELS_NODE_API_PATH, + PROVISION_WORKFLOW_NODE_API_PATH, + DEPROVISION_WORKFLOW_NODE_API_PATH, } from '../common'; /** @@ -30,6 +32,8 @@ export interface RouteService { body: {}, provision?: boolean ) => Promise; + provisionWorkflow: (workflowId: string) => Promise; + deprovisionWorkflow: (workflowId: string) => Promise; deleteWorkflow: (workflowId: string) => Promise; getWorkflowPresets: () => Promise; catIndices: (pattern: string) => Promise; @@ -71,10 +75,10 @@ export function configureRoutes(core: CoreStart): RouteService { return e as HttpFetchError; } }, - createWorkflow: async (body: {}, provision: boolean = false) => { + createWorkflow: async (body: {}) => { try { const response = await core.http.post<{ respString: string }>( - `${CREATE_WORKFLOW_NODE_API_PATH}/${provision}`, + CREATE_WORKFLOW_NODE_API_PATH, { body: JSON.stringify(body), } @@ -84,6 +88,26 @@ export function configureRoutes(core: CoreStart): RouteService { return e as HttpFetchError; } }, + provisionWorkflow: async (workflowId: string) => { + try { + const response = await core.http.post<{ respString: string }>( + `${PROVISION_WORKFLOW_NODE_API_PATH}/${workflowId}` + ); + return response; + } catch (e: any) { + return e as HttpFetchError; + } + }, + deprovisionWorkflow: async (workflowId: string) => { + try { + const response = await core.http.post<{ respString: string }>( + `${DEPROVISION_WORKFLOW_NODE_API_PATH}/${workflowId}` + ); + return response; + } catch (e: any) { + return e as HttpFetchError; + } + }, deleteWorkflow: async (workflowId: string) => { try { const response = await core.http.delete<{ respString: string }>( diff --git a/public/store/reducers/workflows_reducer.ts b/public/store/reducers/workflows_reducer.ts index f748f08b..f8ac6b9d 100644 --- a/public/store/reducers/workflows_reducer.ts +++ b/public/store/reducers/workflows_reducer.ts @@ -16,13 +16,15 @@ const initialState = { }; const WORKFLOWS_ACTION_PREFIX = 'workflows'; -const GET_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/getWorkflow`; -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`; +const GET_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/get`; +const SEARCH_WORKFLOWS_ACTION = `${WORKFLOWS_ACTION_PREFIX}/search`; +const GET_WORKFLOW_STATE_ACTION = `${WORKFLOWS_ACTION_PREFIX}/getState`; +const CREATE_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/create`; +const PROVISION_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/provision`; +const DEPROVISION_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/deprovision`; +const DELETE_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/delete`; +const CACHE_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/cache`; +const CLEAR_CACHED_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/clearCache`; export const getWorkflow = createAsyncThunk( GET_WORKFLOW_ACTION, @@ -77,10 +79,7 @@ export const createWorkflow = createAsyncThunk( async (workflowBody: {}, { rejectWithValue }) => { const response: | any - | HttpFetchError = await getRouteService().createWorkflow( - workflowBody, - false - ); + | HttpFetchError = await getRouteService().createWorkflow(workflowBody); if (response instanceof HttpFetchError) { return rejectWithValue( 'Error creating workflow: ' + response.body.message @@ -91,6 +90,40 @@ export const createWorkflow = createAsyncThunk( } ); +export const provisionWorkflow = createAsyncThunk( + PROVISION_WORKFLOW_ACTION, + async (workflowId: string, { rejectWithValue }) => { + const response: + | any + | HttpFetchError = await getRouteService().provisionWorkflow(workflowId); + if (response instanceof HttpFetchError) { + return rejectWithValue( + 'Error provisioning workflow: ' + response.body.message + ); + } else { + return response; + } + } +); + +export const deprovisionWorkflow = createAsyncThunk( + DEPROVISION_WORKFLOW_ACTION, + async (workflowId: string, { rejectWithValue }) => { + const response: + | any + | HttpFetchError = await getRouteService().deprovisionWorkflow( + workflowId + ); + if (response instanceof HttpFetchError) { + return rejectWithValue( + 'Error deprovisioning workflow: ' + response.body.message + ); + } else { + return response; + } + } +); + export const deleteWorkflow = createAsyncThunk( DELETE_WORKFLOW_ACTION, async (workflowId: string, { rejectWithValue }) => { @@ -140,6 +173,14 @@ const workflowsSlice = createSlice({ state.loading = true; state.errorMessage = ''; }) + .addCase(provisionWorkflow.pending, (state, action) => { + state.loading = true; + state.errorMessage = ''; + }) + .addCase(deprovisionWorkflow.pending, (state, action) => { + state.loading = true; + state.errorMessage = ''; + }) .addCase(deleteWorkflow.pending, (state, action) => { state.loading = true; state.errorMessage = ''; @@ -167,22 +208,31 @@ const workflowsSlice = createSlice({ state.errorMessage = ''; }) .addCase(getWorkflowState.fulfilled, (state, action) => { - // TODO: add logic to mutate state - // const workflow = action.payload; - // state.workflows = { - // ...state.workflows, - // [workflow.id]: workflow, - // }; + const { workflowId, workflowState } = action.payload; + state.workflows = { + ...state.workflows, + [workflowId]: { + ...state.workflows[workflowId], + state: workflowState, + }, + }; state.loading = false; state.errorMessage = ''; }) .addCase(createWorkflow.fulfilled, (state, action) => { - // TODO: add logic to mutate state - // const workflow = action.payload; - // state.workflows = { - // ...state.workflows, - // [workflow.id]: workflow, - // }; + const workflow = action.payload; + state.workflows = { + ...state.workflows, + [workflow.id]: workflow, + }; + state.loading = false; + state.errorMessage = ''; + }) + .addCase(provisionWorkflow.fulfilled, (state, action) => { + state.loading = false; + state.errorMessage = ''; + }) + .addCase(deprovisionWorkflow.fulfilled, (state, action) => { state.loading = false; state.errorMessage = ''; }) @@ -216,6 +266,14 @@ const workflowsSlice = createSlice({ state.errorMessage = action.payload as string; state.loading = false; }) + .addCase(provisionWorkflow.rejected, (state, action) => { + state.errorMessage = action.payload as string; + state.loading = false; + }) + .addCase(deprovisionWorkflow.rejected, (state, action) => { + state.errorMessage = action.payload as string; + state.loading = false; + }) .addCase(deleteWorkflow.rejected, (state, action) => { state.errorMessage = action.payload as string; state.loading = false; diff --git a/public/store/store.ts b/public/store/store.ts index 06d7d2ea..da1aa929 100644 --- a/public/store/store.ts +++ b/public/store/store.ts @@ -3,8 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { configureStore } from '@reduxjs/toolkit'; -import { combineReducers } from 'redux'; +import { ThunkDispatch, configureStore } from '@reduxjs/toolkit'; +import { AnyAction, combineReducers } from 'redux'; +import { useDispatch } from 'react-redux'; import { workspaceReducer, opensearchReducer, @@ -26,3 +27,5 @@ export const store = configureStore({ }); export type AppState = ReturnType; +export type AppThunkDispatch = ThunkDispatch; +export const useAppDispatch = () => useDispatch(); diff --git a/server/cluster/flow_framework_plugin.ts b/server/cluster/flow_framework_plugin.ts index 2e649a4b..690047a2 100644 --- a/server/cluster/flow_framework_plugin.ts +++ b/server/cluster/flow_framework_plugin.ts @@ -67,15 +67,35 @@ export function flowFrameworkPlugin(Client: any, config: any, components: any) { flowFramework.createWorkflow = ca({ url: { - fmt: `${FLOW_FRAMEWORK_WORKFLOW_ROUTE_PREFIX}?provision=<%=provision%>`, + fmt: `${FLOW_FRAMEWORK_WORKFLOW_ROUTE_PREFIX}?provision=false`, + }, + needBody: true, + method: 'POST', + }); + + flowFramework.provisionWorkflow = ca({ + url: { + fmt: `${FLOW_FRAMEWORK_WORKFLOW_ROUTE_PREFIX}/<%=workflow_id%>/_provision`, req: { - provision: { - type: 'boolean', + workflow_id: { + type: 'string', + required: true, + }, + }, + }, + method: 'POST', + }); + + flowFramework.deprovisionWorkflow = ca({ + url: { + fmt: `${FLOW_FRAMEWORK_WORKFLOW_ROUTE_PREFIX}/<%=workflow_id%>/_deprovision`, + req: { + workflow_id: { + type: 'string', required: true, }, }, }, - needBody: true, method: 'POST', }); diff --git a/server/routes/flow_framework_routes_service.ts b/server/routes/flow_framework_routes_service.ts index 4eda47b5..83fe7580 100644 --- a/server/routes/flow_framework_routes_service.ts +++ b/server/routes/flow_framework_routes_service.ts @@ -16,14 +16,22 @@ import { import { CREATE_WORKFLOW_NODE_API_PATH, DELETE_WORKFLOW_NODE_API_PATH, + DEPROVISION_WORKFLOW_NODE_API_PATH, GET_PRESET_WORKFLOWS_NODE_API_PATH, GET_WORKFLOW_NODE_API_PATH, GET_WORKFLOW_STATE_NODE_API_PATH, + PROVISION_WORKFLOW_NODE_API_PATH, SEARCH_WORKFLOWS_NODE_API_PATH, + WORKFLOW_STATE, + Workflow, WorkflowTemplate, validateWorkflowTemplate, } from '../../common'; -import { generateCustomError, getWorkflowsFromResponses } from './helpers'; +import { + generateCustomError, + getWorkflowStateFromResponse, + getWorkflowsFromResponses, +} from './helpers'; /** * Server-side routes to process flow-framework-related node API calls and execute the @@ -69,15 +77,36 @@ export function registerFlowFrameworkRoutes( router.post( { - path: `${CREATE_WORKFLOW_NODE_API_PATH}/{provision}`, + path: CREATE_WORKFLOW_NODE_API_PATH, validate: { body: schema.any(), + }, + }, + flowFrameworkRoutesService.createWorkflow + ); + + router.post( + { + path: `${PROVISION_WORKFLOW_NODE_API_PATH}/{workflow_id}`, + validate: { params: schema.object({ - provision: schema.boolean(), + workflow_id: schema.string(), }), }, }, - flowFrameworkRoutesService.createWorkflow + flowFrameworkRoutesService.provisionWorkflow + ); + + router.post( + { + path: `${DEPROVISION_WORKFLOW_NODE_API_PATH}/{workflow_id}`, + validate: { + params: schema.object({ + workflow_id: schema.string(), + }), + }, + }, + flowFrameworkRoutesService.deprovisionWorkflow ); router.delete( @@ -157,7 +186,6 @@ export class FlowFrameworkRoutesService { } }; - // TODO: test e2e getWorkflowState = async ( context: RequestHandlerContext, req: OpenSearchDashboardsRequest, @@ -168,27 +196,66 @@ export class FlowFrameworkRoutesService { const response = await this.client .asScoped(req) .callAsCurrentUser('flowFramework.getWorkflowState', { workflow_id }); - console.log('response from get workflow state: ', response); - // TODO: format response - return res.ok({ body: response }); + const state = getWorkflowStateFromResponse( + response.state as typeof WORKFLOW_STATE + ); + return res.ok({ + body: { workflowId: workflow_id, workflowState: state }, + }); } catch (err: any) { return generateCustomError(res, err); } }; - // TODO: test e2e createWorkflow = async ( context: RequestHandlerContext, req: OpenSearchDashboardsRequest, res: OpenSearchDashboardsResponseFactory ): Promise> => { - const body = req.body; - const { provision } = req.params as { provision: boolean }; + const body = req.body as Workflow; try { const response = await this.client .asScoped(req) - .callAsCurrentUser('flowFramework.createWorkflow', { body, provision }); - return res.ok({ body: { id: response._id } }); + .callAsCurrentUser('flowFramework.createWorkflow', { body }); + const workflowWithId = { + ...body, + id: response.workflow_id, + }; + return res.ok({ body: { workflow: workflowWithId } }); + } catch (err: any) { + return generateCustomError(res, err); + } + }; + + provisionWorkflow = async ( + context: RequestHandlerContext, + req: OpenSearchDashboardsRequest, + res: OpenSearchDashboardsResponseFactory + ): Promise> => { + const { workflow_id } = req.params as { workflow_id: string }; + try { + await this.client + .asScoped(req) + .callAsCurrentUser('flowFramework.provisionWorkflow', { workflow_id }); + return res.ok(); + } catch (err: any) { + return generateCustomError(res, err); + } + }; + + deprovisionWorkflow = async ( + context: RequestHandlerContext, + req: OpenSearchDashboardsRequest, + res: OpenSearchDashboardsResponseFactory + ): Promise> => { + const { workflow_id } = req.params as { workflow_id: string }; + try { + await this.client + .asScoped(req) + .callAsCurrentUser('flowFramework.deprovisionWorkflow', { + workflow_id, + }); + return res.ok(); } catch (err: any) { return generateCustomError(res, err); } diff --git a/server/routes/helpers.ts b/server/routes/helpers.ts index 5b912b49..c3c671be 100644 --- a/server/routes/helpers.ts +++ b/server/routes/helpers.ts @@ -4,6 +4,7 @@ */ import { + DEFAULT_NEW_WORKFLOW_STATE_TYPE, Model, ModelDict, WORKFLOW_STATE, @@ -55,15 +56,13 @@ export function getWorkflowsFromResponses( const workflowStateHit = workflowStateHits.find( (workflowStateHit) => workflowStateHit._id === workflowHit._id ); - if (workflowStateHit) { - const workflowState = workflowStateHit._source - .state as typeof WORKFLOW_STATE; - workflowDict[workflowHit._id] = { - ...workflowDict[workflowHit._id], - // @ts-ignore - state: WORKFLOW_STATE[workflowState], - }; - } + const workflowState = (workflowStateHit?._source?.state || + DEFAULT_NEW_WORKFLOW_STATE_TYPE) as typeof WORKFLOW_STATE; + workflowDict[workflowHit._id] = { + ...workflowDict[workflowHit._id], + // @ts-ignore + state: WORKFLOW_STATE[workflowState], + }; }); return workflowDict; } @@ -81,3 +80,11 @@ export function getModelsFromResponses(modelHits: any[]): ModelDict { }); return modelDict; } + +export function getWorkflowStateFromResponse( + state: typeof WORKFLOW_STATE | undefined +): WORKFLOW_STATE { + const finalState = state || DEFAULT_NEW_WORKFLOW_STATE_TYPE; + // @ts-ignore + return WORKFLOW_STATE[finalState]; +}