Skip to content

Commit

Permalink
Move undo & save buttons in top-level header (#435) (#440)
Browse files Browse the repository at this point in the history
(cherry picked from commit d4123fa)

Signed-off-by: Tyler Ohlsen <[email protected]>
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
1 parent fb47cca commit c1736ec
Show file tree
Hide file tree
Showing 7 changed files with 507 additions and 427 deletions.
4 changes: 4 additions & 0 deletions common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,3 +480,7 @@ export const MODEL_OUTPUT_SCHEMA_NESTED_PATH =
'output.properties.inference_results.items.properties.output.items.properties.dataAsMap.properties';
export const MODEL_OUTPUT_SCHEMA_FULL_PATH = 'output.properties';
export const PROMPT_FIELD = 'prompt'; // TODO: likely expand to support a pattern and/or multiple (e.g., "prompt", "prompt_template", etc.)
export enum CONFIG_STEP {
INGEST = 'Ingestion pipeline',
SEARCH = 'Search pipeline',
}
245 changes: 201 additions & 44 deletions public/pages/workflow_detail/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,26 @@ import {
EuiText,
EuiSmallButtonEmpty,
EuiSmallButton,
EuiSmallButtonIcon,
} from '@elastic/eui';
import {
PLUGIN_ID,
MAX_WORKFLOW_NAME_TO_DISPLAY,
Workflow,
getCharacterLimitedString,
toFormattedDate,
WorkflowConfig,
WorkflowTemplate,
WorkflowFormValues,
CONFIG_STEP,
} from '../../../../common';
import {
APP_PATH,
SHOW_ACTIONS_IN_HEADER,
constructUrlWithParams,
getDataSourceId,
dataSourceFilterFn,
formikToUiConfig,
} from '../../../utils';
import { ExportModal } from './export_modal';
import {
Expand All @@ -42,21 +48,43 @@ import { DataSourceViewConfig } from '../../../../../../src/plugins/data_source_
import { HeaderVariant } from '../../../../../../src/core/public';
import {
TopNavControlTextData,
TopNavMenuData,
TopNavMenuIconData,
} from '../../../../../../src/plugins/navigation/public';
import { MountPoint } from '../../../../../../src/core/public';
import { getWorkflow, updateWorkflow, useAppDispatch } from '../../../store';
import { useFormikContext } from 'formik';
import { isEmpty, isEqual } from 'lodash';

interface WorkflowDetailHeaderProps {
workflow?: Workflow;
uiConfig: WorkflowConfig | undefined;
setUiConfig: (uiConfig: WorkflowConfig) => void;
isRunningIngest: boolean;
isRunningSearch: boolean;
selectedStep: CONFIG_STEP;
unsavedIngestProcessors: boolean;
setUnsavedIngestProcessors: (unsavedIngestProcessors: boolean) => void;
unsavedSearchProcessors: boolean;
setUnsavedSearchProcessors: (unsavedSearchProcessors: boolean) => void;
setActionMenu: (menuMount?: MountPoint) => void;
}

export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) {
const dispatch = useAppDispatch();
const history = useHistory();
const { resetForm, setTouched, values, touched, dirty } = useFormikContext<
WorkflowFormValues
>();

// workflow state
const [workflowName, setWorkflowName] = useState<string>('');
const [workflowLastUpdated, setWorkflowLastUpdated] = useState<string>('');
const workflowName = getCharacterLimitedString(
props.workflow?.name,
MAX_WORKFLOW_NAME_TO_DISPLAY
);
const workflowLastUpdated = toFormattedDate(
// @ts-ignore
props.workflow?.lastUpdated
).toString();

// export modal state
const [isExportModalOpen, setIsExportModalOpen] = useState<boolean>(false);
Expand All @@ -69,26 +97,6 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) {
chrome: { setHeaderVariant },
} = getCore();

useEffect(() => {
if (props.workflow) {
setWorkflowName(
getCharacterLimitedString(
props.workflow.name,
MAX_WORKFLOW_NAME_TO_DISPLAY
)
);
try {
const formattedDate = toFormattedDate(
// @ts-ignore
props.workflow.lastUpdated
).toString();
setWorkflowLastUpdated(formattedDate);
} catch (err) {
setWorkflowLastUpdated('');
}
}
}, [props.workflow]);

// When NewHomePage is enabled, use 'application' HeaderVariant; otherwise, use 'page' HeaderVariant (default).
useEffect(() => {
if (SHOW_ACTIONS_IN_HEADER) {
Expand All @@ -109,29 +117,13 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) {
);
};

const topNavConfig: TopNavMenuData[] = [
{
iconType: 'exportAction',
tooltip: 'Export',
ariaLabel: 'Export',
run: onExportButtonClick,
controlType: 'icon',
} as TopNavMenuIconData,
{
iconType: 'exit',
tooltip: 'Return to projects',
ariaLabel: 'Exit',
run: onExitButtonClick,
controlType: 'icon',
} as TopNavMenuIconData,
];

let renderDataSourceComponent: ReactElement | null = null;
// get & render the data source component, if applicable
let DataSourceComponent: ReactElement | null = null;
if (dataSourceEnabled && getDataSourceManagementPlugin()) {
const DataSourceMenu = getDataSourceManagementPlugin().ui.getDataSourceMenu<
DataSourceViewConfig
>();
renderDataSourceComponent = (
DataSourceComponent = (
<DataSourceMenu
setMenuMountPoint={props.setActionMenu}
componentType={'DataSourceView'}
Expand All @@ -146,6 +138,121 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) {
);
}

// transient running state
const [isRunningSave, setIsRunningSave] = useState<boolean>(false);

// listener when ingest processors have been added/deleted.
// compare to the indexed/persisted workflow config
useEffect(() => {
props.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(() => {
props.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,
]);

// button eligibility states
const ingestUndoButtonDisabled =
isRunningSave || props.isRunningIngest
? true
: props.unsavedIngestProcessors
? false
: !dirty;
const ingestSaveButtonDisabled = ingestUndoButtonDisabled;
const searchUndoButtonDisabled =
isRunningSave || props.isRunningSearch
? true
: props.unsavedSearchProcessors
? false
: isEmpty(touched?.search) || !dirty;
const searchSaveButtonDisabled = searchUndoButtonDisabled;
const undoDisabled =
props.selectedStep === CONFIG_STEP.INGEST
? ingestUndoButtonDisabled
: searchUndoButtonDisabled;
const saveDisabled =
props.selectedStep === CONFIG_STEP.INGEST
? ingestSaveButtonDisabled
: searchSaveButtonDisabled;

// Utility fn to update the workflow UI config only, based on the current form values.
// A get workflow API call is subsequently run to fetch the updated state.
async function updateWorkflowUiConfig() {
let success = false;
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) => {
success = true;
props.setUnsavedIngestProcessors(false);
props.setUnsavedSearchProcessors(false);
setTouched({});
new Promise((f) => setTimeout(f, 1000)).then(async () => {
dispatch(
getWorkflow({
workflowId: props.workflow?.id as string,
dataSourceId,
})
);
});
})
.catch((error: any) => {
console.error('Error saving workflow: ', error);
})
.finally(() => {
setIsRunningSave(false);
});
return success;
}

// Utility fn to revert any unsaved changes, reset the form
function revertUnsavedChanges(): void {
resetForm();
if (
(props.unsavedIngestProcessors || props.unsavedSearchProcessors) &&
props.workflow?.ui_metadata?.config !== undefined
) {
props.setUiConfig(props.workflow?.ui_metadata?.config);
}
}

return (
<>
{isExportModalOpen && (
Expand All @@ -158,7 +265,38 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) {
<>
<TopNavMenu
appName={PLUGIN_ID}
config={topNavConfig}
config={[
{
iconType: 'editorUndo',
tooltip: 'Revert changes',
ariaLabel: 'Revert',
run: revertUnsavedChanges,
controlType: 'icon',
disabled: undoDisabled,
} as TopNavMenuIconData,
{
iconType: 'save',
tooltip: 'Save',
ariaLabel: 'Save',
run: updateWorkflowUiConfig,
controlType: 'icon',
disabled: saveDisabled,
} as TopNavMenuIconData,
{
iconType: 'exportAction',
tooltip: 'Export',
ariaLabel: 'Export',
run: onExportButtonClick,
controlType: 'icon',
} as TopNavMenuIconData,
{
iconType: 'exit',
tooltip: 'Return to projects',
ariaLabel: 'Exit',
run: onExitButtonClick,
controlType: 'icon',
} as TopNavMenuIconData,
]}
screenTitle={workflowName}
showDataSourceMenu={dataSourceEnabled}
dataSourceMenuConfig={
Expand Down Expand Up @@ -196,7 +334,7 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) {
</>
) : (
<>
{dataSourceEnabled && renderDataSourceComponent}
{dataSourceEnabled && DataSourceComponent}
<EuiPageHeader
style={{ marginTop: '-8px' }}
pageTitle={
Expand Down Expand Up @@ -230,6 +368,25 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) {
>
Close
</EuiSmallButtonEmpty>,
<EuiSmallButtonEmpty
style={{ marginTop: '8px' }}
disabled={saveDisabled}
isLoading={isRunningSave}
onClick={() => {
updateWorkflowUiConfig();
}}
>
{`Save`}
</EuiSmallButtonEmpty>,
<EuiSmallButtonIcon
style={{ marginTop: '8px' }}
iconType="editorUndo"
aria-label="undo changes"
isDisabled={undoDisabled}
onClick={() => {
revertUnsavedChanges();
}}
/>,
<EuiText style={{ marginTop: '14px' }} color="subdued" size="s">
{`Last updated: ${workflowLastUpdated}`}
</EuiText>,
Expand Down
Loading

0 comments on commit c1736ec

Please sign in to comment.