diff --git a/x-pack/plugins/index_lifecycle_management/integration_tests/helpers/setup_environment.tsx b/x-pack/plugins/index_lifecycle_management/integration_tests/helpers/setup_environment.tsx index 91aebb485ea7f..be7a2a104bfa4 100644 --- a/x-pack/plugins/index_lifecycle_management/integration_tests/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_lifecycle_management/integration_tests/helpers/setup_environment.tsx @@ -17,6 +17,10 @@ import { fatalErrorsServiceMock, docLinksServiceMock, executionContextServiceMock, + overlayServiceMock, + applicationServiceMock, + httpServiceMock, + scopedHistoryMock, } from '@kbn/core/public/mocks'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { init as initHttp } from '../../public/application/services/http'; @@ -31,8 +35,12 @@ const appContextMock = { breadcrumbService, license: licensingMock.createLicense({ license: { type: 'enterprise' } }), docLinks: docLinksServiceMock.createStartContract(), - getUrlForApp: () => {}, + getUrlForApp: applicationServiceMock.createStartContract().getUrlForApp, executionContext: executionContextServiceMock.createSetupContract(), + navigateToUrl: applicationServiceMock.createStartContract().navigateToUrl, + overlays: overlayServiceMock.createStartContract(), + http: httpServiceMock.createSetupContract(), + history: scopedHistoryMock.create(), }; export const WithAppDependencies = diff --git a/x-pack/plugins/index_lifecycle_management/public/application/index.tsx b/x-pack/plugins/index_lifecycle_management/public/application/index.tsx index 6360bee1af571..bb004766d89f7 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/index.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/index.tsx @@ -21,10 +21,8 @@ import { import { App } from './app'; import { BreadcrumbService } from './services/breadcrumbs'; -type StartServices = Pick<CoreStart, 'analytics' | 'i18n' | 'theme'>; - export const renderApp = ( - startServices: StartServices, + startServices: CoreStart, element: Element, history: ScopedHistory, application: ApplicationStart, @@ -34,7 +32,9 @@ export const renderApp = ( executionContext: ExecutionContextStart, cloud?: CloudSetup ): UnmountCallback => { - const { getUrlForApp } = application; + const { navigateToUrl, getUrlForApp } = application; + const { overlays, http } = startServices; + render( <KibanaRenderContextProvider {...startServices}> <div className={APP_WRAPPER_CLASS}> @@ -51,6 +51,10 @@ export const renderApp = ( getUrlForApp, docLinks, executionContext, + navigateToUrl, + overlays, + http, + history, }} > <App history={history} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index b63f0b595a540..83faaa3bf28f7 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -7,11 +7,10 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useUnsavedChangesPrompt } from '@kbn/unsaved-changes-prompt'; import React, { Fragment, useEffect, useMemo, useState } from 'react'; import { get } from 'lodash'; -import { useHistory } from 'react-router-dom'; - import './edit_policy.scss'; import { @@ -27,7 +26,13 @@ import { EuiTimeline, } from '@elastic/eui'; -import { TextField, useForm, useFormData, useKibana } from '../../../shared_imports'; +import { + TextField, + useForm, + useFormData, + useKibana, + useFormIsModified, +} from '../../../shared_imports'; import { toasts } from '../../services/notification'; import { UseField } from './form'; import { savePolicy } from './save_policy'; @@ -69,10 +74,11 @@ export const EditPolicy: React.FunctionComponent = () => { } = useEditPolicyContext(); const { - services: { cloud, docLinks }, + services: { cloud, docLinks, history, navigateToUrl, overlays, http }, } = useKibana(); const [isClonedPolicy, setIsClonedPolicy] = useState(false); + const [hasSubmittedForm, setHasSubmittedForm] = useState<boolean>(false); const originalPolicyName: string = isNewPolicy ? '' : policyName!; const isAllowedByLicense = license.canUseSearchableSnapshot(); const isCloudEnabled = Boolean(cloud?.isCloudEnabled); @@ -105,6 +111,8 @@ export const EditPolicy: React.FunctionComponent = () => { }); const [formData] = useFormData({ form, watch: policyNamePath }); + const isFormDirty = useFormIsModified({ form }); + const getPolicyName = () => { return isNewPolicy || isClonedPolicy ? get(formData, policyNamePath) : originalPolicyName; }; @@ -119,7 +127,6 @@ export const EditPolicy: React.FunctionComponent = () => { [originalPolicyName, existingPolicies, isClonedPolicy] ); - const history = useHistory(); const backToPolicyList = () => { history.push('/policies'); }; @@ -134,6 +141,7 @@ export const EditPolicy: React.FunctionComponent = () => { }) ); } else { + setHasSubmittedForm(true); const success = await savePolicy( { ...policy, @@ -141,6 +149,7 @@ export const EditPolicy: React.FunctionComponent = () => { }, isNewPolicy || isClonedPolicy ); + if (success) { backToPolicyList(); } @@ -151,6 +160,21 @@ export const EditPolicy: React.FunctionComponent = () => { setIsShowingPolicyJsonFlyout(!isShowingPolicyJsonFlyout); }; + useUnsavedChangesPrompt({ + titleText: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.unsavedPrompt.title', { + defaultMessage: 'Exit without saving changes?', + }), + messageText: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.unsavedPrompt.body', { + defaultMessage: + 'The data will be lost if you leave this page without saving the policy changes.', + }), + hasUnsavedChanges: isFormDirty && hasSubmittedForm === false, + openConfirm: overlays.openConfirm, + history, + http, + navigateToUrl, + }); + return ( <> <EuiPageHeader diff --git a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts index 7538dc66de002..1c907fdd2b5c7 100644 --- a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts +++ b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts @@ -28,6 +28,7 @@ export { getFieldValidityAndErrorMessage, useFormContext, UseMultiFields, + useFormIsModified, } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; export { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; diff --git a/x-pack/plugins/index_lifecycle_management/public/types.ts b/x-pack/plugins/index_lifecycle_management/public/types.ts index 40772dbe12884..6ea4c4d2b18ae 100644 --- a/x-pack/plugins/index_lifecycle_management/public/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ApplicationStart } from '@kbn/core/public'; +import { ApplicationStart, HttpSetup, OverlayStart, ScopedHistory } from '@kbn/core/public'; import { DocLinksStart } from '@kbn/core/public'; import { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; @@ -41,5 +41,9 @@ export interface AppServicesContext { license: ILicense; cloud?: CloudSetup; getUrlForApp: ApplicationStart['getUrlForApp']; + navigateToUrl: ApplicationStart['navigateToUrl']; docLinks: DocLinksStart; + overlays: OverlayStart; + http: HttpSetup; + history: ScopedHistory; } diff --git a/x-pack/plugins/index_lifecycle_management/tsconfig.json b/x-pack/plugins/index_lifecycle_management/tsconfig.json index 231a9775bf4ab..b9249bc2212f8 100644 --- a/x-pack/plugins/index_lifecycle_management/tsconfig.json +++ b/x-pack/plugins/index_lifecycle_management/tsconfig.json @@ -39,6 +39,7 @@ "@kbn/shared-ux-link-redirect-app", "@kbn/index-management", "@kbn/react-kibana-context-render", + "@kbn/unsaved-changes-prompt", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx index 30637d00f495c..80c43af7b7d4d 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx @@ -157,10 +157,11 @@ export const PipelineForm: React.FunctionComponent<PipelineFormProps> = ({ */ useUnsavedChangesPrompt({ titleText: i18n.translate('xpack.ingestPipelines.form.unsavedPrompt.title', { - defaultMessage: `Exit pipeline creation without saving changes?`, + defaultMessage: 'Exit 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`, + defaultMessage: + 'The data will be lost if you leave this page without saving the pipeline changes.', }), hasUnsavedChanges: (isFormDirty || areProcessorsDirty) && !hasSubmittedForm, openConfirm: overlays.openConfirm, diff --git a/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts b/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts index 71056c2d836fc..8d32181210fcd 100644 --- a/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts +++ b/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts @@ -18,6 +18,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const esClient = getService('es'); const security = getService('security'); const deployment = getService('deployment'); + const testSubjects = getService('testSubjects'); describe('Home page', function () { before(async () => { @@ -80,5 +81,19 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(createdPolicy.length).to.be(1); }); + + it('Shows a prompt when trying to navigate away from the creation form when the form is dirty', async () => { + await pageObjects.indexLifecycleManagement.clickCreatePolicyButton(); + + await pageObjects.indexLifecycleManagement.fillNewPolicyForm({ + policyName, + }); + + // 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); + }); }); }; diff --git a/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts b/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts index b9cfebfbbb40b..a0061dff067d1 100644 --- a/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts +++ b/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts @@ -27,6 +27,9 @@ export function IndexLifecycleManagementPageProvider({ getService }: FtrProvider async createPolicyButton() { return await testSubjects.find('createPolicyButton'); }, + async clickCreatePolicyButton() { + return await testSubjects.click('createPolicyButton'); + }, async fillNewPolicyForm(policy: Policy) { const { policyName,