[Ingest Pipelines] Add unsaved changes prompt (#183699)

This commit is contained in:
Ignacio Rivas 2024-06-07 20:35:56 +02:00 committed by GitHub
parent 1ec9412e82
commit 1e197cf718
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 421 additions and 6 deletions

1
.github/CODEOWNERS vendored
View file

@ -893,6 +893,7 @@ examples/unified_field_list_examples @elastic/kibana-data-discovery
src/plugins/unified_histogram @elastic/kibana-data-discovery
src/plugins/unified_search @elastic/kibana-visualizations
packages/kbn-unsaved-changes-badge @elastic/kibana-data-discovery
packages/kbn-unsaved-changes-prompt @elastic/kibana-management
x-pack/plugins/upgrade_assistant @elastic/kibana-management
x-pack/plugins/observability_solution/uptime @elastic/obs-ux-infra_services-team
x-pack/plugins/drilldowns/url_drilldown @elastic/appex-sharedux

View file

@ -148,6 +148,7 @@
"unifiedHistogram": "src/plugins/unified_histogram",
"unifiedDataTable": "packages/kbn-unified-data-table",
"unsavedChangesBadge": "packages/kbn-unsaved-changes-badge",
"unsavedChangesPrompt": "packages/kbn-unsaved-changes-prompt",
"managedContentBadge": "packages/kbn-managed-content-badge"
},
"translations": []

View file

@ -889,6 +889,7 @@
"@kbn/unified-histogram-plugin": "link:src/plugins/unified_histogram",
"@kbn/unified-search-plugin": "link:src/plugins/unified_search",
"@kbn/unsaved-changes-badge": "link:packages/kbn-unsaved-changes-badge",
"@kbn/unsaved-changes-prompt": "link:packages/kbn-unsaved-changes-prompt",
"@kbn/upgrade-assistant-plugin": "link:x-pack/plugins/upgrade_assistant",
"@kbn/uptime-plugin": "link:x-pack/plugins/observability_solution/uptime",
"@kbn/url-drilldown-plugin": "link:x-pack/plugins/drilldowns/url_drilldown",

View file

@ -0,0 +1,30 @@
# @kbn/unsaved-changes-prompt
The useUnsavedChangesPrompt function is a custom React hook that prompts users with
a confirmation dialog when they try to leave a page with unsaved changes. It blocks
navigation and shows a dialog using the provided openConfirm function. If the user
confirms, it navigates away; otherwise, it cancels the navigation, ensuring unsaved
changes are not lost.
```typescript
import { useUnsavedChangesPrompt } from '@kbn/unsaved-changes-prompt';
export const SampleForm = ({ servicesForUnsavedChangesPrompt }) => {
const { form } = useForm();
const isFormDirty = useFormIsModified({ form });
useUnsavedChangesPrompt({
hasUnsavedChanges: isFormDirty,
...servicesForUnsavedChangesPrompt,
});
return (
<>
<Form form={form}>
....
</Form>
</>
);
};
```

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { useUnsavedChangesPrompt } from './src/unsaved_changes_prompt';

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-unsaved-changes-prompt'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/unsaved-changes-prompt",
"owner": "@elastic/kibana-management"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/unsaved-changes-prompt",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { useUnsavedChangesPrompt } from './unsaved_changes_prompt';

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { createMemoryHistory } from 'history';
import { renderHook, act } from '@testing-library/react-hooks';
import { coreMock } from '@kbn/core/public/mocks';
import { CoreScopedHistory } from '@kbn/core/public';
import { useUnsavedChangesPrompt } from './unsaved_changes_prompt';
const basePath = '/mock';
const memoryHistory = createMemoryHistory({ initialEntries: [basePath] });
const history = new CoreScopedHistory(memoryHistory, basePath);
const coreStart = coreMock.createStart();
const navigateToUrl = jest.fn().mockImplementation(async (url) => {
history.push(url);
});
describe('useUnsavedChangesPrompt', () => {
it('should not block if not edited', () => {
renderHook(() =>
useUnsavedChangesPrompt({
hasUnsavedChanges: false,
http: coreStart.http,
openConfirm: coreStart.overlays.openConfirm,
history,
navigateToUrl,
})
);
act(() => history.push('/test'));
expect(history.location.pathname).toBe('/test');
expect(history.location.search).toBe('');
expect(coreStart.overlays.openConfirm).not.toBeCalled();
});
it('should block if edited', async () => {
coreStart.overlays.openConfirm.mockResolvedValue(true);
renderHook(() =>
useUnsavedChangesPrompt({
hasUnsavedChanges: true,
http: coreStart.http,
openConfirm: coreStart.overlays.openConfirm,
history,
navigateToUrl,
})
);
act(() => history.push('/test'));
// needed because we have an async useEffect
await act(() => new Promise((resolve) => resolve()));
expect(navigateToUrl).toBeCalledWith('/mock/test', expect.anything());
expect(coreStart.overlays.openConfirm).toBeCalled();
});
});

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { ApplicationStart, ScopedHistory, OverlayStart, HttpStart } from '@kbn/core/public';
const DEFAULT_BODY_TEXT = i18n.translate('unsavedChangesPrompt.defaultModalText', {
defaultMessage: `The data will be lost if you leave this page without saving the changes.`,
});
const DEFAULT_TITLE_TEXT = i18n.translate('unsavedChangesPrompt.defaultModalTitle', {
defaultMessage: 'Discard unsaved changes?',
});
const DEFAULT_CANCEL_BUTTON = i18n.translate('unsavedChangesPrompt.defaultModalCancel', {
defaultMessage: 'Keep editing',
});
const DEFAULT_CONFIRM_BUTTON = i18n.translate('unsavedChangesPrompt.defaultModalConfirm', {
defaultMessage: 'Leave page',
});
interface Props {
hasUnsavedChanges: boolean;
http: HttpStart;
openConfirm: OverlayStart['openConfirm'];
history: ScopedHistory;
navigateToUrl: ApplicationStart['navigateToUrl'];
titleText?: string;
messageText?: string;
cancelButtonText?: string;
confirmButtonText?: string;
}
export const useUnsavedChangesPrompt = ({
hasUnsavedChanges,
openConfirm,
history,
http,
navigateToUrl,
// Provide overrides for confirm dialog
messageText = DEFAULT_BODY_TEXT,
titleText = DEFAULT_TITLE_TEXT,
confirmButtonText = DEFAULT_CONFIRM_BUTTON,
cancelButtonText = DEFAULT_CANCEL_BUTTON,
}: Props) => {
useEffect(() => {
if (!hasUnsavedChanges) {
return;
}
const unblock = history.block((state) => {
async function confirmAsync() {
const confirmResponse = await openConfirm(messageText, {
title: titleText,
cancelButtonText,
confirmButtonText,
'data-test-subj': 'navigationBlockConfirmModal',
});
if (confirmResponse) {
// Compute the URL we want to redirect to
const url = http.basePath.prepend(state.pathname) + state.hash + state.search;
// Unload history block
unblock();
// Navigate away
navigateToUrl(url, {
state: state.state,
});
}
}
confirmAsync();
return false;
});
return unblock;
}, [
history,
hasUnsavedChanges,
openConfirm,
navigateToUrl,
http.basePath,
titleText,
cancelButtonText,
confirmButtonText,
messageText,
]);
};

View file

@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": ["*.ts", "src/**/*"],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/i18n",
"@kbn/core"
]
}

View file

@ -1780,6 +1780,8 @@
"@kbn/unified-search-plugin/*": ["src/plugins/unified_search/*"],
"@kbn/unsaved-changes-badge": ["packages/kbn-unsaved-changes-badge"],
"@kbn/unsaved-changes-badge/*": ["packages/kbn-unsaved-changes-badge/*"],
"@kbn/unsaved-changes-prompt": ["packages/kbn-unsaved-changes-prompt"],
"@kbn/unsaved-changes-prompt/*": ["packages/kbn-unsaved-changes-prompt/*"],
"@kbn/upgrade-assistant-plugin": ["x-pack/plugins/upgrade_assistant"],
"@kbn/upgrade-assistant-plugin/*": ["x-pack/plugins/upgrade_assistant/*"],
"@kbn/uptime-plugin": ["x-pack/plugins/observability_solution/uptime"],

View file

@ -19,7 +19,9 @@ import {
scopedHistoryMock,
uiSettingsServiceMock,
applicationServiceMock,
overlayServiceMock,
} from '@kbn/core/public/mocks';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/public/mocks';
@ -66,6 +68,8 @@ const appServices = {
share: {
url: new MockUrlService(),
},
overlays: overlayServiceMock.createStartContract(),
http: httpServiceMock.createStartContract({ basePath: '/mock' }),
};
export const setupEnvironment = () => {

View file

@ -6,14 +6,18 @@
*/
import React, { useState, useCallback, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { useForm, Form, FormConfig } from '../../../shared_imports';
import { useUnsavedChangesPrompt } from '@kbn/unsaved-changes-prompt';
import { Pipeline, Processor } from '../../../../common/types';
import { useForm, Form, FormConfig, useFormIsModified } from '../../../shared_imports';
import { useKibana } from '../../../shared_imports';
import { OnUpdateHandlerArg, OnUpdateHandler } from '../pipeline_editor';
import { deepEqualIgnoreUndefined } from './utils';
import { PipelineRequestFlyout } from './pipeline_request_flyout';
import { PipelineFormFields } from './pipeline_form_fields';
import { PipelineFormError } from './pipeline_form_error';
@ -48,7 +52,16 @@ export const PipelineForm: React.FunctionComponent<PipelineFormProps> = ({
onCancel,
canEditName,
}) => {
const {
overlays,
history,
application: { navigateToUrl },
http,
} = useKibana().services;
const [isRequestVisible, setIsRequestVisible] = useState<boolean>(false);
const [areProcessorsDirty, setAreProcessorsDirty] = useState<boolean>(false);
const [hasSubmittedForm, setHasSubmittedForm] = useState<boolean>(false);
const {
processors: initialProcessors,
@ -74,6 +87,11 @@ export const PipelineForm: React.FunctionComponent<PipelineFormProps> = ({
if (processorStateRef.current) {
const state = processorStateRef.current;
if (await state.validate()) {
// We only want to show unsaved changed prompts to the user when the form
// hasnt been submitted.
setHasSubmittedForm(true);
// Save the form state, this will also trigger a redirect to pipelines list
onSave({ ...formData, ...state.getData() });
}
}
@ -85,6 +103,8 @@ export const PipelineForm: React.FunctionComponent<PipelineFormProps> = ({
onSubmit: handleSave,
});
const isFormDirty = useFormIsModified({ form });
const onEditorFlyoutOpen = useCallback(() => {
setIsRequestVisible(false);
}, [setIsRequestVisible]);
@ -107,10 +127,48 @@ export const PipelineForm: React.FunctionComponent<PipelineFormProps> = ({
);
const onProcessorsChangeHandler = useCallback<OnUpdateHandler>(
(arg) => (processorStateRef.current = arg),
[]
(arg) => {
processorStateRef.current = arg;
const currentProcessorsState = processorStateRef.current?.getData();
// Calculate if the current processor state has changed compared to the
// initial processors state.
setAreProcessorsDirty(
!deepEqualIgnoreUndefined(
{
processors: processorsState?.processors || [],
onFailure: processorsState?.onFailure || [],
},
{
processors: currentProcessorsState?.processors || [],
onFailure: currentProcessorsState?.on_failure || [],
}
)
);
},
[processorsState]
);
/*
We need to check if the form is dirty and also if the form has been submitted.
Because on form submission we also redirect the user to the pipelines list,
and this could otherwise trigger an unwanted unsaved changes prompt.
*/
useUnsavedChangesPrompt({
titleText: i18n.translate('xpack.ingestPipelines.form.unsavedPrompt.title', {
defaultMessage: `Exit pipeline creation without saving changes?`,
}),
messageText: i18n.translate('xpack.ingestPipelines.form.unsavedPrompt.body', {
defaultMessage: `The data will be lost if you leave this page without saving the pipeline changes`,
}),
hasUnsavedChanges: (isFormDirty || areProcessorsDirty) && !hasSubmittedForm,
openConfirm: overlays.openConfirm,
history,
http,
navigateToUrl,
});
return (
<>
<Form

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { removeUndefinedValues, deepEqualIgnoreUndefined } from './utils';
describe('deepEqualIgnoreUndefined', () => {
const testObjectA = Object.freeze({
a: 1,
b: {
c: 2,
d: undefined,
},
});
const testObjectB = Object.freeze({
a: 1,
b: {
c: 2,
d: undefined,
},
});
it('knows how to remove undefined values', () => {
expect(removeUndefinedValues(testObjectA)).toStrictEqual({
a: 1,
b: {
c: 2,
},
});
});
it('knows how to compare two objects and see if they are equal ignoring undefined values', () => {
expect(deepEqualIgnoreUndefined(testObjectA, testObjectB)).toBe(true);
});
});

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { transform, isObject, isEqual } from 'lodash';
export function removeUndefinedValues(obj: object) {
// If the input is an object, recursively clean each key-value pair.
if (isObject(obj)) {
// Use transform to iterate over the object and build a new result object.
return transform(
obj,
(result: Record<string, any>, value: any, key: string) => {
const cleanedValue = removeUndefinedValues(value);
if (cleanedValue !== undefined) {
// Only add the key-value pair if the value is not undefined.
result[key as keyof typeof obj] = cleanedValue;
}
},
{}
);
}
return obj;
}
export function deepEqualIgnoreUndefined(obj1: object, obj2: object) {
// Clean both objects by removing undefined values.
const cleanedObj1 = removeUndefinedValues(obj1);
const cleanedObj2 = removeUndefinedValues(obj2);
return isEqual(cleanedObj1, cleanedObj2);
}

View file

@ -10,7 +10,7 @@ import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { ApplicationStart } from '@kbn/core/public';
import { NotificationsSetup, IUiSettingsClient } from '@kbn/core/public';
import { NotificationsSetup, IUiSettingsClient, OverlayStart, HttpStart } from '@kbn/core/public';
import { ManagementAppMountParams } from '@kbn/management-plugin/public';
import type { ConsolePluginStart } from '@kbn/console-plugin/public';
import type { SharePluginStart } from '@kbn/share-plugin/public';
@ -48,6 +48,8 @@ export interface AppServices {
application: ApplicationStart;
license: ILicense | null;
consolePlugin?: ConsolePluginStart;
overlays: OverlayStart;
http: HttpStart;
}
type StartServices = Pick<CoreStart, 'analytics' | 'i18n' | 'theme'>;

View file

@ -28,7 +28,7 @@ export async function mountManagementSection(
) {
const { element, setBreadcrumbs, history, license } = params;
const [coreStart, depsStart] = await getStartServices();
const { docLinks, application, executionContext } = coreStart;
const { docLinks, application, executionContext, overlays } = coreStart;
documentationService.setup(docLinks);
breadcrumbService.setup(setBreadcrumbs);
@ -49,6 +49,8 @@ export async function mountManagementSection(
executionContext,
license,
consolePlugin: depsStart.console,
overlays,
http,
};
return renderApp(element, services, { ...coreStart, http });

View file

@ -62,6 +62,7 @@ export {
FormDataProvider,
getFieldValidityAndErrorMessage,
useFormData,
useFormIsModified,
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
export { fieldFormatters, fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';

View file

@ -33,7 +33,9 @@
"@kbn/code-editor",
"@kbn/react-kibana-context-render",
"@kbn/console-plugin",
"@kbn/react-kibana-context-theme"
"@kbn/react-kibana-context-theme",
"@kbn/unsaved-changes-prompt",
"@kbn/core-http-browser-mocks"
],
"exclude": [
"target/**/*",

View file

@ -133,6 +133,22 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
});
});
it('Shows a prompt when trying to navigate away from the creation form when the form is dirty', async () => {
// Navigate to creation flow
await testSubjects.click('createPipelineDropdown');
await testSubjects.click('createNewPipeline');
// Fill in the form with some data
await testSubjects.setValue('nameField > input', 'test_name');
await testSubjects.setValue('descriptionField > input', 'test_description');
// Try to navigate to another page
await testSubjects.click('logo');
// Since the form is now dirty it should trigger a confirmation prompt
expect(await testSubjects.exists('navigationBlockConfirmModal')).to.be(true);
});
describe('Create pipeline', () => {
afterEach(async () => {
// Delete the pipeline that was created

View file

@ -6728,6 +6728,10 @@
version "0.0.0"
uid ""
"@kbn/unsaved-changes-prompt@link:packages/kbn-unsaved-changes-prompt":
version "0.0.0"
uid ""
"@kbn/upgrade-assistant-plugin@link:x-pack/plugins/upgrade_assistant":
version "0.0.0"
uid ""