diff --git a/common/constants.ts b/common/constants.ts
index a7f442cc..fc186934 100644
--- a/common/constants.ts
+++ b/common/constants.ts
@@ -37,6 +37,7 @@ 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 UPDATE_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/update`;
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`;
diff --git a/public/pages/workflow_detail/component_details/component_details.tsx b/public/pages/workflow_detail/component_details/component_details.tsx
index 242ae81d..41ed85d0 100644
--- a/public/pages/workflow_detail/component_details/component_details.tsx
+++ b/public/pages/workflow_detail/component_details/component_details.tsx
@@ -5,15 +5,18 @@
import React from 'react';
import { EuiPanel } from '@elastic/eui';
-import { ReactFlowComponent } from '../../../../common';
+import { ReactFlowComponent, Workflow } from '../../../../common';
import { ComponentInputs } from './component_inputs';
import { EmptyComponentInputs } from './empty_component_inputs';
+import { ProvisionedComponentInputs } from './provisioned_component_inputs';
// styling
import '../workspace/workspace-styles.scss';
interface ComponentDetailsProps {
+ workflow: Workflow | undefined;
onFormChange: () => void;
+ isDeprovisionable: boolean;
selectedComponent?: ReactFlowComponent;
}
@@ -25,14 +28,16 @@ interface ComponentDetailsProps {
export function ComponentDetails(props: ComponentDetailsProps) {
return (
- {props.selectedComponent ? (
+ {props.isDeprovisionable ? (
+
+ ) : props.selectedComponent ? (
- ) : (
+ ) : props.workflow ? (
- )}
+ ) : undefined}
);
}
diff --git a/public/pages/workflow_detail/component_details/empty_component_inputs.tsx b/public/pages/workflow_detail/component_details/empty_component_inputs.tsx
index 18a19880..19ce0754 100644
--- a/public/pages/workflow_detail/component_details/empty_component_inputs.tsx
+++ b/public/pages/workflow_detail/component_details/empty_component_inputs.tsx
@@ -6,6 +6,7 @@
import React from 'react';
import { EuiEmptyPrompt, EuiText } from '@elastic/eui';
+// Simple prompt to display when no components are selected.
export function EmptyComponentInputs() {
return (
The workflow has been provisioned}
+ titleSize="s"
+ body={
+ <>
+ Please deprovision first to continue editing.
+ >
+ }
+ />
+ );
+}
diff --git a/public/pages/workflow_detail/workspace/resizable_workspace.tsx b/public/pages/workflow_detail/workspace/resizable_workspace.tsx
index 041ebf01..d0d1e7bb 100644
--- a/public/pages/workflow_detail/workspace/resizable_workspace.tsx
+++ b/public/pages/workflow_detail/workspace/resizable_workspace.tsx
@@ -35,6 +35,7 @@ import {
USE_CASE,
WORKFLOW_STATE,
processNodes,
+ reduceToTemplate,
} from '../../../../common';
import {
AppState,
@@ -44,6 +45,7 @@ import {
provisionWorkflow,
removeDirty,
setDirty,
+ updateWorkflow,
useAppDispatch,
} from '../../../store';
import { Workspace } from './workspace';
@@ -103,16 +105,20 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
>();
// Save/provision/deprovision button state
- const isSaveable = isFirstSave ? true : isDirty;
+ const isSaveable =
+ props.workflow !== undefined && (isFirstSave ? true : isDirty);
const isProvisionable =
+ props.workflow !== undefined &&
!isDirty &&
!props.isNewWorkflow &&
formValidOnSubmit &&
flowValidOnSubmit &&
props.workflow?.state === WORKFLOW_STATE.NOT_STARTED;
const isDeprovisionable =
+ props.workflow !== undefined &&
!props.isNewWorkflow &&
props.workflow?.state !== WORKFLOW_STATE.NOT_STARTED;
+ const readonly = props.workflow === undefined || isDeprovisionable;
// Loading state
const [isProvisioning, setIsProvisioning] = useState(false);
@@ -376,7 +382,7 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
,
{
@@ -390,8 +396,21 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
// 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
+ dispatch(
+ updateWorkflow({
+ workflowId: updatedWorkflow.id,
+ workflowTemplate: reduceToTemplate(updatedWorkflow),
+ })
+ )
+ .unwrap()
+ .then((result) => {
+ setIsSaving(false);
+ })
+ .catch((error: any) => {
+ // TODO: process error (toast msg?)
+ console.log('error: ', error);
+ setIsSaving(false);
+ });
} else {
dispatch(createWorkflow(updatedWorkflow))
.unwrap()
@@ -444,6 +463,7 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
@@ -467,7 +487,9 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
>
diff --git a/public/pages/workflow_detail/workspace/workspace.tsx b/public/pages/workflow_detail/workspace/workspace.tsx
index 234e3518..b85ea94d 100644
--- a/public/pages/workflow_detail/workspace/workspace.tsx
+++ b/public/pages/workflow_detail/workspace/workspace.tsx
@@ -38,6 +38,7 @@ import './workspace_edge/deletable-edge-styles.scss';
interface WorkspaceProps {
workflow?: Workflow;
+ readonly: boolean;
onNodesChange: (nodes: ReactFlowComponent[]) => void;
id: string;
// TODO: make more typesafe
@@ -134,6 +135,14 @@ export function Workspace(props: WorkspaceProps) {
onConnect={onConnect}
className="reactflow-workspace"
fitView
+ edgesUpdatable={!props.readonly}
+ edgesFocusable={!props.readonly}
+ nodesDraggable={!props.readonly}
+ nodesConnectable={!props.readonly}
+ nodesFocusable={!props.readonly}
+ draggable={!props.readonly}
+ panOnDrag={!props.readonly}
+ elementsSelectable={!props.readonly}
>
Promise;
searchWorkflows: (body: {}) => Promise;
getWorkflowState: (workflowId: string) => Promise;
- createWorkflow: (
- body: {},
- provision?: boolean
+ createWorkflow: (body: {}) => Promise;
+ updateWorkflow: (
+ workflowId: string,
+ workflowTemplate: WorkflowTemplate
) => Promise;
provisionWorkflow: (workflowId: string) => Promise;
deprovisionWorkflow: (workflowId: string) => Promise;
@@ -88,6 +91,22 @@ export function configureRoutes(core: CoreStart): RouteService {
return e as HttpFetchError;
}
},
+ updateWorkflow: async (
+ workflowId: string,
+ workflowTemplate: WorkflowTemplate
+ ) => {
+ try {
+ const response = await core.http.put<{ respString: string }>(
+ `${UPDATE_WORKFLOW_NODE_API_PATH}/${workflowId}`,
+ {
+ body: JSON.stringify(workflowTemplate),
+ }
+ );
+ return response;
+ } catch (e: any) {
+ return e as HttpFetchError;
+ }
+ },
provisionWorkflow: async (workflowId: string) => {
try {
const response = await core.http.post<{ respString: string }>(
diff --git a/public/store/reducers/workflows_reducer.ts b/public/store/reducers/workflows_reducer.ts
index f8ac6b9d..d8e6e18b 100644
--- a/public/store/reducers/workflows_reducer.ts
+++ b/public/store/reducers/workflows_reducer.ts
@@ -4,7 +4,7 @@
*/
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
-import { Workflow, WorkflowDict } from '../../../common';
+import { Workflow, WorkflowDict, WorkflowTemplate } from '../../../common';
import { HttpFetchError } from '../../../../../src/core/public';
import { getRouteService } from '../../services';
@@ -20,6 +20,7 @@ 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 UPDATE_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/update`;
const PROVISION_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/provision`;
const DEPROVISION_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/deprovision`;
const DELETE_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/delete`;
@@ -90,6 +91,29 @@ export const createWorkflow = createAsyncThunk(
}
);
+export const updateWorkflow = createAsyncThunk(
+ UPDATE_WORKFLOW_ACTION,
+ async (
+ workflowInfo: { workflowId: string; workflowTemplate: WorkflowTemplate },
+ { rejectWithValue }
+ ) => {
+ const { workflowId, workflowTemplate } = workflowInfo;
+ const response:
+ | any
+ | HttpFetchError = await getRouteService().updateWorkflow(
+ workflowId,
+ workflowTemplate
+ );
+ if (response instanceof HttpFetchError) {
+ return rejectWithValue(
+ 'Error updating workflow: ' + response.body.message
+ );
+ } else {
+ return response;
+ }
+ }
+);
+
export const provisionWorkflow = createAsyncThunk(
PROVISION_WORKFLOW_ACTION,
async (workflowId: string, { rejectWithValue }) => {
@@ -173,6 +197,10 @@ const workflowsSlice = createSlice({
state.loading = true;
state.errorMessage = '';
})
+ .addCase(updateWorkflow.pending, (state, action) => {
+ state.loading = true;
+ state.errorMessage = '';
+ })
.addCase(provisionWorkflow.pending, (state, action) => {
state.loading = true;
state.errorMessage = '';
@@ -228,6 +256,22 @@ const workflowsSlice = createSlice({
state.loading = false;
state.errorMessage = '';
})
+ .addCase(updateWorkflow.fulfilled, (state, action) => {
+ const { workflowId, workflowTemplate } = action.payload as {
+ workflowId: string;
+ workflowTemplate: WorkflowTemplate;
+ };
+ state.workflows = {
+ ...state.workflows,
+ [workflowId]: {
+ // only overwrite the stateless / template fields. persist any existing state (e.g., lastUpdated, lastProvisioned)
+ ...state.workflows[workflowId],
+ ...workflowTemplate,
+ },
+ };
+ state.loading = false;
+ state.errorMessage = '';
+ })
.addCase(provisionWorkflow.fulfilled, (state, action) => {
state.loading = false;
state.errorMessage = '';
@@ -266,6 +310,10 @@ const workflowsSlice = createSlice({
state.errorMessage = action.payload as string;
state.loading = false;
})
+ .addCase(updateWorkflow.rejected, (state, action) => {
+ state.errorMessage = action.payload as string;
+ state.loading = false;
+ })
.addCase(provisionWorkflow.rejected, (state, action) => {
state.errorMessage = action.payload as string;
state.loading = false;
diff --git a/public/utils/utils.ts b/public/utils/utils.ts
index 75ea61a3..daca9c23 100644
--- a/public/utils/utils.ts
+++ b/public/utils/utils.ts
@@ -16,6 +16,8 @@ import {
WorkspaceFormValues,
WORKFLOW_STATE,
ReactFlowComponent,
+ Workflow,
+ WorkflowTemplate,
} from '../../common';
// Append 16 random characters
@@ -72,6 +74,19 @@ export function formikToComponentData(
} as IComponentData;
}
+// Helper fn to remove state-related fields from a workflow and have a stateless template
+// to export and/or pass around, use when updating, etc.
+export function reduceToTemplate(workflow: Workflow): WorkflowTemplate {
+ const {
+ id,
+ lastUpdated,
+ lastLaunched,
+ state,
+ ...workflowTemplate
+ } = workflow;
+ return workflowTemplate;
+}
+
// Helper fn to get an initial value based on the field type
export function getInitialValue(fieldType: FieldType): FieldValue {
switch (fieldType) {
diff --git a/server/cluster/flow_framework_plugin.ts b/server/cluster/flow_framework_plugin.ts
index 690047a2..4f9b0990 100644
--- a/server/cluster/flow_framework_plugin.ts
+++ b/server/cluster/flow_framework_plugin.ts
@@ -73,6 +73,20 @@ export function flowFrameworkPlugin(Client: any, config: any, components: any) {
method: 'POST',
});
+ flowFramework.updateWorkflow = ca({
+ url: {
+ fmt: `${FLOW_FRAMEWORK_WORKFLOW_ROUTE_PREFIX}/<%=workflow_id%>`,
+ req: {
+ workflow_id: {
+ type: 'string',
+ required: true,
+ },
+ },
+ },
+ needBody: true,
+ method: 'PUT',
+ });
+
flowFramework.provisionWorkflow = ca({
url: {
fmt: `${FLOW_FRAMEWORK_WORKFLOW_ROUTE_PREFIX}/<%=workflow_id%>/_provision`,
diff --git a/server/routes/flow_framework_routes_service.ts b/server/routes/flow_framework_routes_service.ts
index 83fe7580..d9ee6190 100644
--- a/server/routes/flow_framework_routes_service.ts
+++ b/server/routes/flow_framework_routes_service.ts
@@ -22,6 +22,7 @@ import {
GET_WORKFLOW_STATE_NODE_API_PATH,
PROVISION_WORKFLOW_NODE_API_PATH,
SEARCH_WORKFLOWS_NODE_API_PATH,
+ UPDATE_WORKFLOW_NODE_API_PATH,
WORKFLOW_STATE,
Workflow,
WorkflowTemplate,
@@ -85,6 +86,19 @@ export function registerFlowFrameworkRoutes(
flowFrameworkRoutesService.createWorkflow
);
+ router.put(
+ {
+ path: `${UPDATE_WORKFLOW_NODE_API_PATH}/{workflow_id}`,
+ validate: {
+ params: schema.object({
+ workflow_id: schema.string(),
+ }),
+ body: schema.any(),
+ },
+ },
+ flowFrameworkRoutesService.updateWorkflow
+ );
+
router.post(
{
path: `${PROVISION_WORKFLOW_NODE_API_PATH}/{workflow_id}`,
@@ -227,6 +241,28 @@ export class FlowFrameworkRoutesService {
}
};
+ updateWorkflow = async (
+ context: RequestHandlerContext,
+ req: OpenSearchDashboardsRequest,
+ res: OpenSearchDashboardsResponseFactory
+ ): Promise> => {
+ const { workflow_id } = req.params as { workflow_id: string };
+ const workflowTemplate = req.body as WorkflowTemplate;
+
+ try {
+ await this.client
+ .asScoped(req)
+ .callAsCurrentUser('flowFramework.updateWorkflow', {
+ workflow_id,
+ body: workflowTemplate,
+ });
+
+ return res.ok({ body: { workflowId: workflow_id, workflowTemplate } });
+ } catch (err: any) {
+ return generateCustomError(res, err);
+ }
+ };
+
provisionWorkflow = async (
context: RequestHandlerContext,
req: OpenSearchDashboardsRequest,