diff --git a/common/interfaces.ts b/common/interfaces.ts index 357373ca..aa31c7d2 100644 --- a/common/interfaces.ts +++ b/common/interfaces.ts @@ -27,6 +27,7 @@ export type MDSQueryParams = { export type ConfigFieldType = | 'string' + | 'textArea' | 'json' | 'jsonArray' | 'jsonString' diff --git a/public/app.tsx b/public/app.tsx index 132bcc07..0bedd168 100644 --- a/public/app.tsx +++ b/public/app.tsx @@ -29,7 +29,6 @@ interface Props extends RouteComponentProps { export const FlowFrameworkDashboardsApp = (props: Props) => { const { setHeaderActionMenu } = props; - // Render the application DOM. return ( void; setActionMenu: (menuMount?: MountPoint) => void; + setBlockNavigation: (blockNavigation: boolean) => void; } export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) { const dispatch = useAppDispatch(); - const history = useHistory(); const { resetForm, setTouched, values, touched, dirty } = useFormikContext< WorkflowFormValues >(); @@ -116,12 +115,6 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) { }; }, [setHeaderVariant, USE_NEW_HOME_PAGE]); - const onExitButtonClick = () => { - history.replace( - constructUrlWithParams(APP_PATH.WORKFLOWS, undefined, dataSourceId) - ); - }; - // get & render the data source component, if applicable let DataSourceComponent: ReactElement | null = null; if (dataSourceEnabled && getDataSourceManagementPlugin() && dataSourceId) { @@ -200,21 +193,8 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) { ? ingestSaveButtonDisabled : searchSaveButtonDisabled; - // Add warning when exiting with unsaved changes - function alertFn(e: BeforeUnloadEvent) { - e.preventDefault(); - } - - // Hook for warning of unsaved changes if a browser refresh is detected useEffect(() => { - if (!saveDisabled) { - window.addEventListener('beforeunload', alertFn); - } else { - window.removeEventListener('beforeunload', alertFn); - } - return () => { - window.removeEventListener('beforeunload', alertFn); - }; + props.setBlockNavigation(!saveDisabled); }, [saveDisabled]); // Utility fn to update the workflow UI config only, based on the current form values. @@ -319,7 +299,10 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) { iconType: 'exit', tooltip: 'Return to projects', ariaLabel: 'Exit', - run: onExitButtonClick, + href: constructHrefWithDataSourceId( + APP_PATH.WORKFLOWS, + dataSourceId + ), controlType: 'icon', } as TopNavMenuIconData, { @@ -407,15 +390,10 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) { Export , { - history.replace( - constructUrlWithParams( - APP_PATH.WORKFLOWS, - undefined, - dataSourceId - ) - ); - }} + href={constructHrefWithDataSourceId( + APP_PATH.WORKFLOWS, + dataSourceId + )} data-testid="closeButton" > Close @@ -430,6 +408,7 @@ export function WorkflowDetailHeader(props: WorkflowDetailHeaderProps) { {`Save`} , { ); }); }); - - test('tests navigation to workflows list on Close button click', async () => { - const { getByTestId, history } = renderWithRouter( - workflowId, - workflowName, - WORKFLOW_TYPE.CUSTOM - ); - // The WorkflowDetail Page Close button should navigate back to the workflows list - userEvent.click(getByTestId('closeButton')); - await waitFor(() => { - expect(history.location.pathname).toBe('/workflows'); - }); - }); }); describe('WorkflowDetail Page with skip ingestion option (Hybrid Search Workflow)', () => { diff --git a/public/pages/workflow_detail/workflow_detail.tsx b/public/pages/workflow_detail/workflow_detail.tsx index 7fe3d449..33d3b16b 100644 --- a/public/pages/workflow_detail/workflow_detail.tsx +++ b/public/pages/workflow_detail/workflow_detail.tsx @@ -4,7 +4,7 @@ */ import React, { useEffect, useState } from 'react'; -import { RouteComponentProps } from 'react-router-dom'; +import { Prompt, RouteComponentProps, useHistory } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { ReactFlowProvider } from 'reactflow'; import { escape } from 'lodash'; @@ -79,6 +79,7 @@ interface WorkflowDetailProps export function WorkflowDetail(props: WorkflowDetailProps) { const dispatch = useAppDispatch(); + const history = useHistory(); // On initial load: // - fetch workflow @@ -96,6 +97,55 @@ export function WorkflowDetail(props: WorkflowDetailProps) { dispatch(setSearchPipelineErrors({ errors: {} })); }, []); + const [blockNavigation, setBlockNavigation] = useState(false); + + // 1. Block page refreshes if unsaved changes. + // Remove listeners on component unload. + function preventPageRefresh(e: BeforeUnloadEvent) { + e.preventDefault(); + } + useEffect(() => { + if (blockNavigation) { + window.addEventListener('beforeunload', preventPageRefresh); + } else { + window.removeEventListener('beforeunload', preventPageRefresh); + } + return () => { + window.removeEventListener('beforeunload', preventPageRefresh); + }; + }, [blockNavigation]); + + // 2. Block navigation (externally-controlled buttons/links) + // Remove listeners on component unload. + const handleLinkClick = (e: Event) => { + const confirmation = window.confirm( + 'You have unsaved changes. Are you sure you want to leave?' + ); + if (!confirmation) { + e.preventDefault(); + } + }; + useEffect(() => { + // try to catch as many external links as possible, particularly + // ones that will go to the home page, or different plugins within + // the side navigation. + const links = document.querySelectorAll(`a[href*="app/"]`); + if (blockNavigation) { + links.forEach((link) => { + link.addEventListener('click', handleLinkClick); + }); + } else { + links.forEach((link) => { + link.removeEventListener('click', handleLinkClick); + }); + } + return () => { + links.forEach((link) => { + link.removeEventListener('click', handleLinkClick); + }); + }; + }, [blockNavigation]); + // data-source-related states const dataSourceEnabled = getDataSourceEnabled().enabled; const dataSourceId = getDataSourceId(); @@ -188,71 +238,100 @@ export function WorkflowDetail(props: WorkflowDetailProps) { } }, [uiConfig]); - return errorMessage?.includes(ERROR_GETTING_WORKFLOW_MSG) || - errorMessage?.includes(NO_TEMPLATES_FOUND_MSG) ? ( - - - Oops! We couldn't find that workflow} - titleSize="s" - /> - - - - Return to home - - - - ) : ( - {}} - validate={(values) => {}} - > - - - - - + {/** + * 3. Block navigation (internally-controlled buttons/links). context is confined to navigation checks + * within the plugin-defined router. + */} + { + const confirmation = window.confirm( + 'You have unsaved changes. Are you sure you want to leave?' + ); + if (!confirmation) { + setBlockNavigation(true); + history.goBack(); + return false; + } else { + setBlockNavigation(false); + return true; + } + }} + /> + + {errorMessage?.includes(ERROR_GETTING_WORKFLOW_MSG) || + errorMessage?.includes(NO_TEMPLATES_FOUND_MSG) ? ( + + + Oops! We couldn't find that workflow} + titleSize="s" /> - - - - + + + + Return to home + + + + ) : ( + {}} + validate={(values) => {}} + > + + + + + + + + + + )} + ); } diff --git a/public/pages/workflow_detail/workflow_inputs/config_field_list.tsx b/public/pages/workflow_detail/workflow_inputs/config_field_list.tsx index 4704c5ac..c4680dc9 100644 --- a/public/pages/workflow_detail/workflow_inputs/config_field_list.tsx +++ b/public/pages/workflow_detail/workflow_inputs/config_field_list.tsx @@ -50,6 +50,20 @@ export function ConfigFieldList(props: ConfigFieldListProps) { ); break; } + case 'textArea': { + el = ( + + + + + ); + break; + } case 'select': { el = ( diff --git a/public/pages/workflow_detail/workflow_inputs/processor_inputs/normalization_processor_inputs.tsx b/public/pages/workflow_detail/workflow_inputs/processor_inputs/normalization_processor_inputs.tsx index a0637b9e..396ff750 100644 --- a/public/pages/workflow_detail/workflow_inputs/processor_inputs/normalization_processor_inputs.tsx +++ b/public/pages/workflow_detail/workflow_inputs/processor_inputs/normalization_processor_inputs.tsx @@ -22,7 +22,8 @@ interface NormalizationProcessorInputsProps { } /** - * Specialized component to render the normalization processor. Adds some helper text around weights field. + * Specialized component to render the normalization processor. Adds some helper text around weights field, + * and bubble it up as a primary field to populate (even though it is technically optional). * In the future, may have a more customizable / guided way for specifying the array of weights. * For example, could have some visual way of linking it to the underlying sub-queries in the query field, * enforce its length = the number of queries, etc. @@ -38,30 +39,30 @@ export function NormalizationProcessorInputs( ); return ( - // We only have optional fields for this processor, so everything is nested under the accordion - - + <> + + + - - + - - - - + + ); } diff --git a/public/pages/workflow_detail/workflow_inputs/workflow_inputs.tsx b/public/pages/workflow_detail/workflow_inputs/workflow_inputs.tsx index 9e63accd..7a60e3fd 100644 --- a/public/pages/workflow_detail/workflow_inputs/workflow_inputs.tsx +++ b/public/pages/workflow_detail/workflow_inputs/workflow_inputs.tsx @@ -804,6 +804,8 @@ export function WorkflowInputs(props: WorkflowInputsProps) { disabled={ !ingestEnabled ? false + : onIngestAndUnprovisioned + ? true : ingestTemplatesDifferent } iconSide="right" diff --git a/public/pages/workflows/new_workflow/utils.ts b/public/pages/workflows/new_workflow/utils.ts index 57d983a7..067170d6 100644 --- a/public/pages/workflows/new_workflow/utils.ts +++ b/public/pages/workflows/new_workflow/utils.ts @@ -228,9 +228,7 @@ export function fetchHybridSearchMetadata(version: string): UIState { ); baseState.config.search.enrichResponse.processors = [ - injectDefaultWeightsInNormalizationProcessor( - new NormalizationProcessor().toObj() - ), + new NormalizationProcessor().toObj(), ]; baseState.config.search.enrichRequest.processors = isPreV219 @@ -304,25 +302,3 @@ function injectQueryTemplateInProcessor( ); return processorConfig; } - -// set default weights for a normalization processor. assumes there is 2 queries, and equally -// balances the weight. We don't hardcode in the configuration, since we don't want to set -// invalid defaults for arbitrary use cases (e.g., more than 2 queries). In this case, we -// are already setting 2 queries by default, so we can make this assumption. -function injectDefaultWeightsInNormalizationProcessor( - processorConfig: IProcessorConfig -): IProcessorConfig { - processorConfig.optionalFields = processorConfig.optionalFields?.map( - (optionalField) => { - let updatedField = optionalField; - if (optionalField.id === 'weights') { - updatedField = { - ...updatedField, - value: '0.5, 0.5', - }; - } - return updatedField; - } - ); - return processorConfig; -} diff --git a/public/utils/config_to_form_utils.ts b/public/utils/config_to_form_utils.ts index b1ed4d45..ffeef207 100644 --- a/public/utils/config_to_form_utils.ts +++ b/public/utils/config_to_form_utils.ts @@ -125,6 +125,7 @@ function searchIndexConfigToFormik( export function getInitialValue(fieldType: ConfigFieldType): ConfigFieldValue { switch (fieldType) { case 'string': + case 'textArea': case 'select': case 'jsonLines': { return ''; diff --git a/public/utils/config_to_schema_utils.ts b/public/utils/config_to_schema_utils.ts index 0044fd19..64e52c6f 100644 --- a/public/utils/config_to_schema_utils.ts +++ b/public/utils/config_to_schema_utils.ts @@ -152,6 +152,7 @@ export function getFieldSchema( switch (field.type) { case 'string': + case 'textArea': case 'select': { baseSchema = defaultStringSchema; break;