Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Backport 2.x] Add explicit save / revert buttons in search & ingest forms #362

Merged
merged 1 commit into from
Sep 10, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 174 additions & 75 deletions public/pages/workflow_detail/workflow_inputs/workflow_inputs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
* SPDX-License-Identifier: Apache-2.0
*/

import React, { useCallback, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { getIn, useFormikContext } from 'formik';
import { debounce, isEmpty, isEqual } from 'lodash';
import { isEmpty, isEqual } from 'lodash';
import {
EuiSmallButton,
EuiSmallButtonEmpty,
Expand All @@ -24,6 +24,7 @@ import {
EuiStepsHorizontal,
EuiText,
EuiTitle,
EuiSmallButtonIcon,
} from '@elastic/eui';
import {
MAX_WORKFLOW_NAME_TO_DISPLAY,
Expand Down Expand Up @@ -96,14 +97,17 @@ export function WorkflowInputs(props: WorkflowInputsProps) {
const {
submitForm,
validateForm,
resetForm,
setFieldValue,
setTouched,
values,
touched,
} = useFormikContext<WorkflowFormValues>();
const dispatch = useAppDispatch();
const dataSourceId = getDataSourceId();

// running ingest/search state
// transient running states
const [isRunningSave, setIsRunningSave] = useState<boolean>(false);
const [isRunningIngest, setIsRunningIngest] = useState<boolean>(false);
const [isRunningSearch, setIsRunningSearch] = useState<boolean>(false);
const [isRunningDelete, setIsRunningDelete] = useState<boolean>(false);
Expand All @@ -129,7 +133,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) {
isEmpty(getIn(values, 'search.enrichResponse'));

// maintaining any fine-grained differences between the generated templates produced by the form,
// and the one persisted in the workflow itself. We enable/disable buttons
// produced by the current UI config, and the one persisted in the workflow itself. We enable/disable buttons
// based on any discrepancies found.
const [persistedTemplateNodes, setPersistedTemplateNodes] = useState<
TemplateNode[]
Expand Down Expand Up @@ -159,6 +163,42 @@ export function WorkflowInputs(props: WorkflowInputsProps) {
const [searchTemplatesDifferent, setSearchTemplatesDifferent] = useState<
boolean
>(false);
const [unsavedIngestProcessors, setUnsavedIngestProcessors] = useState<
boolean
>(false);
const [unsavedSearchProcessors, setUnsavedSearchProcessors] = useState<
boolean
>(false);

// listener when ingest processors have been added/deleted.
// compare to the indexed/persisted workflow config
useEffect(() => {
setUnsavedIngestProcessors(
!isEqual(
props.uiConfig?.ingest?.enrich?.processors,
props.workflow?.ui_metadata?.config?.ingest?.enrich?.processors
)
);
}, [props.uiConfig?.ingest?.enrich?.processors?.length]);

// listener when search processors have been added/deleted.
// compare to the indexed/persisted workflow config
useEffect(() => {
setUnsavedSearchProcessors(
!isEqual(
props.uiConfig?.search?.enrichRequest?.processors,
props.workflow?.ui_metadata?.config?.search?.enrichRequest?.processors
) ||
!isEqual(
props.uiConfig?.search?.enrichResponse?.processors,
props.workflow?.ui_metadata?.config?.search?.enrichResponse
?.processors
)
);
}, [
props.uiConfig?.search?.enrichRequest?.processors?.length,
props.uiConfig?.search?.enrichResponse?.processors?.length,
]);

// fetch the total template nodes
useEffect(() => {
Expand Down Expand Up @@ -234,75 +274,64 @@ export function WorkflowInputs(props: WorkflowInputsProps) {
formGeneratedSearchTemplateNodes,
]);

// Auto-save the UI metadata when users update form values.
// Only update the underlying workflow template (deprovision/provision) when
// users explicitly run ingest/search and need to have updated resources
// to test against.
// We use useCallback() with an autosave flag that is only set within the fn itself.
// This is so we can fetch the latest values (uiConfig, formik values) inside a memoized fn,
// but only when we need to.
const [autosave, setAutosave] = useState<boolean>(false);
function triggerAutosave(): void {
setAutosave(!autosave);
}
const debounceAutosave = useCallback(
debounce(async () => {
triggerAutosave();
}, 1000),
[autosave]
);

// Hook to execute autosave when triggered. Runs the update API with update_fields set to true,
// to update the ui_metadata without updating the underlying template for a provisioned workflow.
useEffect(() => {
(async () => {
if (!isEmpty(touched)) {
const updatedTemplate = {
name: props.workflow?.name,
ui_metadata: {
...props.workflow?.ui_metadata,
config: formikToUiConfig(values, props.uiConfig as WorkflowConfig),
},
} as WorkflowTemplate;
await dispatch(
updateWorkflow({
apiBody: {
setIngestProvisioned(hasProvisionedIngestResources(props.workflow));
}, [props.workflow]);

// Utility fn to update the workflow UI config only. A get workflow API call is subsequently run
// to fetch the updated state.
async function updateWorkflowUiConfig() {
setIsRunningSave(true);
const updatedTemplate = {
name: props.workflow?.name,
ui_metadata: {
...props.workflow?.ui_metadata,
config: formikToUiConfig(values, props.uiConfig as WorkflowConfig),
},
} as WorkflowTemplate;
await dispatch(
updateWorkflow({
apiBody: {
workflowId: props.workflow?.id as string,
workflowTemplate: updatedTemplate,
updateFields: true,
reprovision: false,
},
dataSourceId,
})
)
.unwrap()
.then(async (result) => {
setUnsavedIngestProcessors(false);
setUnsavedSearchProcessors(false);
setTouched({});
new Promise((f) => setTimeout(f, 1000)).then(async () => {
dispatch(
getWorkflow({
workflowId: props.workflow?.id as string,
workflowTemplate: updatedTemplate,
updateFields: true,
reprovision: false,
},
dataSourceId,
})
)
.unwrap()
.then(async (result) => {
// TODO: figure out clean way to update the "last updated"
// section. The problem with re-fetching this every time, is it
// triggers lots of component rebuilds due to the base workflow prop
// changing.
// get any updates after autosave
// new Promise((f) => setTimeout(f, 1000)).then(async () => {
// dispatch(getWorkflow(props.workflow?.id as string));
// });
})
.catch((error: any) => {
console.error('Error autosaving workflow: ', error);
});
}
})();
}, [autosave]);
dataSourceId,
})
);
});
})
.catch((error: any) => {
console.error('Error saving workflow: ', error);
})
.finally(() => {
setIsRunningSave(false);
});
}

// Hook to listen for changes to form values and trigger autosave
useEffect(() => {
if (!isEmpty(values)) {
debounceAutosave();
// Utility fn to revert any unsaved changes, reset the form
function revertUnsavedChanges(): void {
resetForm();
if (
(unsavedIngestProcessors || unsavedSearchProcessors) &&
props.workflow?.ui_metadata?.config !== undefined
) {
props.setUiConfig(props.workflow?.ui_metadata?.config);
}
}, [values]);

useEffect(() => {
setIngestProvisioned(hasProvisionedIngestResources(props.workflow));
}, [props.workflow]);
}

// Utility fn to update the workflow, including any updated/new resources.
// The reprovision param is used to determine whether we are doing full
Expand All @@ -327,6 +356,8 @@ export function WorkflowInputs(props: WorkflowInputsProps) {
.unwrap()
.then(async (result) => {
await sleep(1000);
setUnsavedIngestProcessors(false);
setUnsavedSearchProcessors(false);
success = true;
// Kicking off an async task to re-fetch the workflow details
// after some amount of time. Provisioning will finish in an indeterminate
Expand Down Expand Up @@ -370,6 +401,8 @@ export function WorkflowInputs(props: WorkflowInputsProps) {
.unwrap()
.then(async (result) => {
await sleep(1000);
setUnsavedIngestProcessors(false);
setUnsavedSearchProcessors(false);
await dispatch(
provisionWorkflow({
workflowId: updatedWorkflow.id as string,
Expand Down Expand Up @@ -431,11 +464,10 @@ export function WorkflowInputs(props: WorkflowInputsProps) {
...(includeSearch && search !== undefined ? { search } : {}),
};
if (Object.keys(relevantValidationResults).length > 0) {
// TODO: may want to persist more fine-grained form validation (ingest vs. search)
// For example, running an ingest should be possible, even with some
// invalid query or search processor config. And vice versa.
getCore().notifications.toasts.addDanger('Missing or invalid fields');
console.error('Form invalid');
} else {
setTouched({});
const updatedConfig = formikToUiConfig(
values,
props.uiConfig as WorkflowConfig
Expand Down Expand Up @@ -623,7 +655,6 @@ export function WorkflowInputs(props: WorkflowInputsProps) {
.unwrap()
.then(async (result) => {
setFieldValue('ingest.enabled', false);
await validateAndUpdateWorkflow(false);
// @ts-ignore
await dispatch(
getWorkflow({
Expand Down Expand Up @@ -759,6 +790,41 @@ export function WorkflowInputs(props: WorkflowInputsProps) {
</EuiFlexItem>
) : onIngest ? (
<>
<EuiFlexItem grow={false}>
<EuiSmallButtonIcon
iconType="editorUndo"
aria-label="undo changes"
isDisabled={
isRunningSave || isRunningIngest
? true
: unsavedIngestProcessors
? false
: isEmpty(touched?.ingest?.enrich) &&
isEmpty(touched?.ingest?.index)
}
onClick={() => {
revertUnsavedChanges();
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSmallButtonEmpty
disabled={
isRunningSave || isRunningIngest
? true
: unsavedIngestProcessors
? false
: isEmpty(touched?.ingest?.enrich) &&
isEmpty(touched?.ingest?.index)
}
isLoading={isRunningSave}
onClick={() => {
updateWorkflowUiConfig();
}}
>
{`Save`}
</EuiSmallButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSmallButton
fill={false}
Expand All @@ -768,7 +834,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) {
disabled={!ingestTemplatesDifferent}
isLoading={isRunningIngest}
>
Run ingestion
Build and run ingestion
</EuiSmallButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
Expand Down Expand Up @@ -798,6 +864,39 @@ export function WorkflowInputs(props: WorkflowInputsProps) {
Back
</EuiSmallButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSmallButtonIcon
iconType="editorUndo"
aria-label="undo changes"
isDisabled={
isRunningSave || isRunningSearch
? true
: unsavedSearchProcessors
? false
: isEmpty(touched?.search)
}
onClick={() => {
revertUnsavedChanges();
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSmallButtonEmpty
disabled={
isRunningSave || isRunningSearch
? true
: unsavedSearchProcessors
? false
: isEmpty(touched?.search)
}
isLoading={isRunningSave}
onClick={() => {
updateWorkflowUiConfig();
}}
>
{`Save`}
</EuiSmallButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSmallButton
disabled={
Expand All @@ -811,7 +910,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) {
validateAndRunQuery();
}}
>
Run query
Build and run query
</EuiSmallButton>
</EuiFlexItem>
</>
Expand Down
Loading