From 145cc8f13b3ffecbdbb5842f1fc21fce775dcb93 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 13 Nov 2023 13:36:25 +0100 Subject: [PATCH] [Discover] Show "unsaved changes" label when in unsaved state of saved search (#169548) - Resolves https://github.com/elastic/kibana/issues/135887 ## Summary This PR adds "Unsaved changes" badge to Discover for modified saved searches. It also removes "Reset search" button from the histogram area. Code for the badge is added to a new package `@kbn/unsaved-changes-badge`. Screenshot 2023-10-23 at 18 05 34 ![Oct-23-2023 18-06-39](https://github.com/elastic/kibana/assets/1415710/cacf4ff2-525c-4759-aba9-34ce75089ddd) ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 1 + .i18nrc.json | 3 +- .../public/plugin.tsx | 11 ++ package.json | 1 + .../src/hooks/use_row_heights_options.ts | 4 + packages/kbn-unsaved-changes-badge/README.md | 8 + packages/kbn-unsaved-changes-badge/index.ts | 17 ++ .../kbn-unsaved-changes-badge/jest.config.js | 13 ++ .../kbn-unsaved-changes-badge/kibana.jsonc | 5 + .../kbn-unsaved-changes-badge/package.json | 10 + .../unsaved_changes_badge.test.tsx.snap | 95 ++++++++++ .../components/unsaved_changes_badge/index.ts | 9 + .../unsaved_changes_badge.test.tsx | 89 +++++++++ .../unsaved_changes_badge.tsx | 175 ++++++++++++++++++ ...op_nav_unsaved_changes_badge.test.tsx.snap | 63 +++++++ ...get_top_nav_unsaved_changes_badge.test.tsx | 28 +++ .../get_top_nav_unsaved_changes_badge.tsx | 50 +++++ .../kbn-unsaved-changes-badge/tsconfig.json | 14 ++ packages/kbn-unsaved-changes-badge/types.ts | 10 + .../layout/__stories__/get_layout_props.ts | 1 - .../layout/discover_histogram_layout.test.tsx | 22 --- .../layout/discover_histogram_layout.tsx | 8 - .../layout/discover_layout.test.tsx | 1 - .../layout/reset_search_button.test.tsx | 20 -- .../components/layout/reset_search_button.tsx | 35 ---- .../components/top_nav/discover_topnav.tsx | 17 ++ .../top_nav/get_top_nav_badges.test.ts | 100 ++++++++++ .../components/top_nav/get_top_nav_badges.tsx | 57 ++++++ .../components/top_nav/on_save_search.tsx | 6 + .../discover_saved_search_container.ts | 67 +++++-- .../main/services/discover_state.test.ts | 6 + .../main/services/discover_state.ts | 2 +- .../main/utils/update_saved_search.ts | 21 +-- .../top_nav_customization.ts | 27 ++- src/plugins/discover/tsconfig.json | 1 + src/plugins/navigation/public/index.ts | 2 +- .../__snapshots__/top_nav_menu.test.tsx.snap | 48 +++++ .../navigation/public/top_nav_menu/index.ts | 2 +- .../public/top_nav_menu/top_nav_menu.test.tsx | 44 ++++- .../public/top_nav_menu/top_nav_menu.tsx | 23 ++- .../apps/discover/group1/_discover.ts | 2 +- .../discover/group1/_discover_histogram.ts | 2 +- .../apps/discover/group3/_request_counts.ts | 2 +- .../discover/group3/_unsaved_changes_badge.ts | 167 +++++++++++++++++ test/functional/apps/discover/group3/index.ts | 1 + test/functional/page_objects/discover_page.ts | 30 ++- tsconfig.base.json | 2 + .../customizations/log_explorer_profile.tsx | 3 + .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - yarn.lock | 4 + 52 files changed, 1189 insertions(+), 143 deletions(-) create mode 100644 packages/kbn-unsaved-changes-badge/README.md create mode 100644 packages/kbn-unsaved-changes-badge/index.ts create mode 100644 packages/kbn-unsaved-changes-badge/jest.config.js create mode 100644 packages/kbn-unsaved-changes-badge/kibana.jsonc create mode 100644 packages/kbn-unsaved-changes-badge/package.json create mode 100644 packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/__snapshots__/unsaved_changes_badge.test.tsx.snap create mode 100644 packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/index.ts create mode 100644 packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/unsaved_changes_badge.test.tsx create mode 100644 packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/unsaved_changes_badge.tsx create mode 100644 packages/kbn-unsaved-changes-badge/src/utils/__snapshots__/get_top_nav_unsaved_changes_badge.test.tsx.snap create mode 100644 packages/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.test.tsx create mode 100644 packages/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.tsx create mode 100644 packages/kbn-unsaved-changes-badge/tsconfig.json create mode 100644 packages/kbn-unsaved-changes-badge/types.ts delete mode 100644 src/plugins/discover/public/application/main/components/layout/reset_search_button.test.tsx delete mode 100644 src/plugins/discover/public/application/main/components/layout/reset_search_button.tsx create mode 100644 src/plugins/discover/public/application/main/components/top_nav/get_top_nav_badges.test.ts create mode 100644 src/plugins/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx create mode 100644 src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu.test.tsx.snap create mode 100644 test/functional/apps/discover/group3/_unsaved_changes_badge.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5c112d886cd3a..4edca2f43e6a4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -789,6 +789,7 @@ packages/kbn-unified-field-list @elastic/kibana-data-discovery 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 x-pack/plugins/upgrade_assistant @elastic/platform-deployment-management x-pack/plugins/uptime @elastic/obs-ux-infra_services-team x-pack/plugins/drilldowns/url_drilldown @elastic/appex-sharedux diff --git a/.i18nrc.json b/.i18nrc.json index 4a71e696efc1f..c5f1f7053f95a 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -132,7 +132,8 @@ "unifiedSearch": "src/plugins/unified_search", "unifiedFieldList": "packages/kbn-unified-field-list", "unifiedHistogram": "src/plugins/unified_histogram", - "unifiedDataTable": "packages/kbn-unified-data-table" + "unifiedDataTable": "packages/kbn-unified-data-table", + "unsavedChangesBadge": "packages/kbn-unsaved-changes-badge" }, "translations": [] } diff --git a/examples/discover_customization_examples/public/plugin.tsx b/examples/discover_customization_examples/public/plugin.tsx index ca356612d0f8a..5eefb4932cc43 100644 --- a/examples/discover_customization_examples/public/plugin.tsx +++ b/examples/discover_customization_examples/public/plugin.tsx @@ -163,6 +163,17 @@ export class DiscoverCustomizationExamplesPlugin implements Plugin { order: 300, }, ], + getBadges: () => { + return [ + { + data: { + badgeText: 'Example badge', + color: 'warning', + }, + order: 10, + }, + ]; + }, }); customizations.set({ diff --git a/package.json b/package.json index 7db00b814d99d..495fe098ada51 100644 --- a/package.json +++ b/package.json @@ -780,6 +780,7 @@ "@kbn/unified-field-list-examples-plugin": "link:examples/unified_field_list_examples", "@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/upgrade-assistant-plugin": "link:x-pack/plugins/upgrade_assistant", "@kbn/uptime-plugin": "link:x-pack/plugins/uptime", "@kbn/url-drilldown-plugin": "link:x-pack/plugins/drilldowns/url_drilldown", diff --git a/packages/kbn-unified-data-table/src/hooks/use_row_heights_options.ts b/packages/kbn-unified-data-table/src/hooks/use_row_heights_options.ts index 3df1a31aaa9d3..542641e2ece7a 100644 --- a/packages/kbn-unified-data-table/src/hooks/use_row_heights_options.ts +++ b/packages/kbn-unified-data-table/src/hooks/use_row_heights_options.ts @@ -85,6 +85,10 @@ export const useRowHeightsOptions = ({ defaultHeight, lineHeight: rowLineHeight, onChange: ({ defaultHeight: newRowHeight }: EuiDataGridRowHeightsOptions) => { + if (newRowHeight === defaultHeight && typeof rowHeightState === 'undefined') { + // ignore, no changes required + return; + } const newSerializedRowHeight = serializeRowHeight( // pressing "Reset to default" triggers onChange with the same value newRowHeight === defaultHeight ? configRowHeight : newRowHeight diff --git a/packages/kbn-unsaved-changes-badge/README.md b/packages/kbn-unsaved-changes-badge/README.md new file mode 100644 index 0000000000000..3bdcd84425e53 --- /dev/null +++ b/packages/kbn-unsaved-changes-badge/README.md @@ -0,0 +1,8 @@ +# @kbn/unsaved-changes-badge + +A yellow "Unsaved changes" badge which can be found for example on Discover page. +It supports callbacks to save or revert the changes. + +To integrate it into TopNav component, consider using `getTopNavUnsavedChangesBadge(...)` util and pass the result to `badges` prop of the top nav. + +There is also a standalone component ``. diff --git a/packages/kbn-unsaved-changes-badge/index.ts b/packages/kbn-unsaved-changes-badge/index.ts new file mode 100644 index 0000000000000..928e9129ef7ca --- /dev/null +++ b/packages/kbn-unsaved-changes-badge/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { + UnsavedChangesBadge, + type UnsavedChangesBadgeProps, +} from './src/components/unsaved_changes_badge'; + +export { + getTopNavUnsavedChangesBadge, + type TopNavUnsavedChangesBadgeParams, +} from './src/utils/get_top_nav_unsaved_changes_badge'; diff --git a/packages/kbn-unsaved-changes-badge/jest.config.js b/packages/kbn-unsaved-changes-badge/jest.config.js new file mode 100644 index 0000000000000..9c8319e692f9e --- /dev/null +++ b/packages/kbn-unsaved-changes-badge/jest.config.js @@ -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: ['/packages/kbn-unsaved-changes-badge'], +}; diff --git a/packages/kbn-unsaved-changes-badge/kibana.jsonc b/packages/kbn-unsaved-changes-badge/kibana.jsonc new file mode 100644 index 0000000000000..0f64c4d4143fd --- /dev/null +++ b/packages/kbn-unsaved-changes-badge/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/unsaved-changes-badge", + "owner": "@elastic/kibana-data-discovery" +} diff --git a/packages/kbn-unsaved-changes-badge/package.json b/packages/kbn-unsaved-changes-badge/package.json new file mode 100644 index 0000000000000..c9e7aaf964bf0 --- /dev/null +++ b/packages/kbn-unsaved-changes-badge/package.json @@ -0,0 +1,10 @@ +{ + "name": "@kbn/unsaved-changes-badge", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0", + "sideEffects": [ + "*.css", + "*.scss" + ] +} \ No newline at end of file diff --git a/packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/__snapshots__/unsaved_changes_badge.test.tsx.snap b/packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/__snapshots__/unsaved_changes_badge.test.tsx.snap new file mode 100644 index 0000000000000..441d85d917ef3 --- /dev/null +++ b/packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/__snapshots__/unsaved_changes_badge.test.tsx.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should show all menu items 1`] = ` +
+
+
+ +
+
+
+`; + +exports[` should show all menu items 2`] = ` +
+
+ + + +
+
+`; diff --git a/packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/index.ts b/packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/index.ts new file mode 100644 index 0000000000000..0d7afa38c7d21 --- /dev/null +++ b/packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/index.ts @@ -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 { UnsavedChangesBadge, type UnsavedChangesBadgeProps } from './unsaved_changes_badge'; diff --git a/packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/unsaved_changes_badge.test.tsx b/packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/unsaved_changes_badge.test.tsx new file mode 100644 index 0000000000000..4ba81214ed27e --- /dev/null +++ b/packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/unsaved_changes_badge.test.tsx @@ -0,0 +1,89 @@ +/* + * 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 React from 'react'; +import { render, act, screen, waitFor } from '@testing-library/react'; +import { UnsavedChangesBadge } from './unsaved_changes_badge'; + +describe('', () => { + test('should render correctly', async () => { + const onRevert = jest.fn(); + const { getByTestId, queryByTestId } = render( + + ); + expect(getByTestId('unsavedChangesBadge')).toBeInTheDocument(); + + getByTestId('unsavedChangesBadge').click(); + await waitFor(() => { + return Boolean(queryByTestId('unsavedChangesBadgeMenuPanel')); + }); + expect(queryByTestId('revertUnsavedChangesButton')).toBeInTheDocument(); + expect(queryByTestId('saveUnsavedChangesButton')).not.toBeInTheDocument(); + expect(queryByTestId('saveUnsavedChangesAsButton')).not.toBeInTheDocument(); + + expect(onRevert).not.toHaveBeenCalled(); + + act(() => { + getByTestId('revertUnsavedChangesButton').click(); + }); + expect(onRevert).toHaveBeenCalled(); + }); + + test('should show all menu items', async () => { + const onRevert = jest.fn().mockResolvedValue(true); + const onSave = jest.fn().mockResolvedValue(true); + const onSaveAs = jest.fn().mockResolvedValue(true); + const { getByTestId, queryByTestId, container } = render( + + ); + + expect(container).toMatchSnapshot(); + + getByTestId('unsavedChangesBadge').click(); + await waitFor(() => { + return Boolean(queryByTestId('unsavedChangesBadgeMenuPanel')); + }); + expect(queryByTestId('revertUnsavedChangesButton')).toBeInTheDocument(); + expect(queryByTestId('saveUnsavedChangesButton')).toBeInTheDocument(); + expect(queryByTestId('saveUnsavedChangesAsButton')).toBeInTheDocument(); + + expect(screen.getByTestId('unsavedChangesBadgeMenuPanel')).toMatchSnapshot(); + }); + + test('should call callbacks', async () => { + const onRevert = jest.fn().mockResolvedValue(true); + const onSave = jest.fn().mockResolvedValue(true); + const onSaveAs = jest.fn().mockResolvedValue(true); + const { getByTestId, queryByTestId } = render( + + ); + act(() => { + getByTestId('unsavedChangesBadge').click(); + }); + await waitFor(() => { + return Boolean(queryByTestId('unsavedChangesBadgeMenuPanel')); + }); + + expect(onSave).not.toHaveBeenCalled(); + + act(() => { + getByTestId('saveUnsavedChangesButton').click(); + }); + expect(onSave).toHaveBeenCalled(); + }); +}); diff --git a/packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/unsaved_changes_badge.tsx b/packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/unsaved_changes_badge.tsx new file mode 100644 index 0000000000000..a022214f0df79 --- /dev/null +++ b/packages/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/unsaved_changes_badge.tsx @@ -0,0 +1,175 @@ +/* + * 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 React, { useState } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; +import { + EuiBadge, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiPopover, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +enum ProcessingType { + reverting = 'reverting', + saving = 'saving', + savingAs = 'savingAs', +} + +/** + * Props for UnsavedChangesBadge + */ +export interface UnsavedChangesBadgeProps { + onRevert: () => Promise; + onSave?: () => Promise; + onSaveAs?: () => Promise; + badgeText: string; +} + +/** + * Badge component. It opens a menu panel with actions once pressed. + * @param badgeText + * @param onRevert + * @param onSave + * @param onSaveAs + * @constructor + */ +export const UnsavedChangesBadge: React.FC = ({ + badgeText, + onRevert, + onSave, + onSaveAs, +}) => { + const isMounted = useMountedState(); + const [processingType, setProcessingType] = useState(null); + const [isPopoverOpen, setPopover] = useState(false); + const contextMenuPopoverId = useGeneratedHtmlId({ + prefix: 'unsavedChangesPopover', + }); + + const togglePopover = () => { + setPopover((value) => !value); + }; + + const closePopover = () => { + setPopover(false); + }; + + const completeMenuItemAction = () => { + if (isMounted()) { + setProcessingType(null); + closePopover(); + } + }; + + const handleMenuItem = async (type: ProcessingType, action: () => Promise) => { + try { + setProcessingType(type); + await action(); + } finally { + completeMenuItemAction(); + } + }; + + const disabled = Boolean(processingType); + const isSaving = processingType === ProcessingType.saving; + const isSavingAs = processingType === ProcessingType.savingAs; + const isReverting = processingType === ProcessingType.reverting; + + const items = [ + ...(onSave + ? [ + { + await handleMenuItem(ProcessingType.saving, onSave); + }} + > + {isSaving + ? i18n.translate('unsavedChangesBadge.contextMenu.savingChangesButtonStatus', { + defaultMessage: 'Saving...', + }) + : i18n.translate('unsavedChangesBadge.contextMenu.saveChangesButton', { + defaultMessage: 'Save', + })} + , + ] + : []), + ...(onSaveAs + ? [ + { + await handleMenuItem(ProcessingType.savingAs, onSaveAs); + }} + > + {isSavingAs + ? i18n.translate('unsavedChangesBadge.contextMenu.savingChangesAsButtonStatus', { + defaultMessage: 'Saving as...', + }) + : i18n.translate('unsavedChangesBadge.contextMenu.saveChangesAsButton', { + defaultMessage: 'Save as', + })} + , + ] + : []), + { + await handleMenuItem(ProcessingType.reverting, onRevert); + }} + > + {isReverting + ? i18n.translate('unsavedChangesBadge.contextMenu.revertingChangesButtonStatus', { + defaultMessage: 'Reverting changes...', + }) + : i18n.translate('unsavedChangesBadge.contextMenu.revertChangesButton', { + defaultMessage: 'Revert changes', + })} + , + ]; + + const button = ( + + {badgeText} + + ); + + return ( + + + + ); +}; diff --git a/packages/kbn-unsaved-changes-badge/src/utils/__snapshots__/get_top_nav_unsaved_changes_badge.test.tsx.snap b/packages/kbn-unsaved-changes-badge/src/utils/__snapshots__/get_top_nav_unsaved_changes_badge.test.tsx.snap new file mode 100644 index 0000000000000..eafe487e0265c --- /dev/null +++ b/packages/kbn-unsaved-changes-badge/src/utils/__snapshots__/get_top_nav_unsaved_changes_badge.test.tsx.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getTopNavUnsavedChangesBadge() should work correctly 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`getTopNavUnsavedChangesBadge() should work correctly 2`] = ` +
+
+ +
+
+`; diff --git a/packages/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.test.tsx b/packages/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.test.tsx new file mode 100644 index 0000000000000..f60b92fec5176 --- /dev/null +++ b/packages/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.test.tsx @@ -0,0 +1,28 @@ +/* + * 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 { render, waitFor, screen } from '@testing-library/react'; +import { getTopNavUnsavedChangesBadge } from './get_top_nav_unsaved_changes_badge'; + +describe('getTopNavUnsavedChangesBadge()', () => { + test('should work correctly', async () => { + const onRevert = jest.fn().mockResolvedValue(true); + const badge = getTopNavUnsavedChangesBadge({ onRevert }); + const { container, getByTestId, queryByTestId } = render( + badge.renderCustomBadge!({ badgeText: badge.badgeText }) + ); + expect(container).toMatchSnapshot(); + + getByTestId('unsavedChangesBadge').click(); + await waitFor(() => { + return Boolean(queryByTestId('revertUnsavedChangesButton')); + }); + + expect(screen.getByTestId('unsavedChangesBadgeMenuPanel')).toMatchSnapshot(); + }); +}); diff --git a/packages/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.tsx b/packages/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.tsx new file mode 100644 index 0000000000000..af7cf915bb103 --- /dev/null +++ b/packages/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.tsx @@ -0,0 +1,50 @@ +/* + * 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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import type { TopNavMenuBadgeProps } from '@kbn/navigation-plugin/public'; +import { + UnsavedChangesBadge, + type UnsavedChangesBadgeProps, +} from '../components/unsaved_changes_badge'; + +/** + * Params for getTopNavUnsavedChangesBadge + */ +export interface TopNavUnsavedChangesBadgeParams { + onRevert: UnsavedChangesBadgeProps['onRevert']; + onSave?: UnsavedChangesBadgeProps['onSave']; + onSaveAs?: UnsavedChangesBadgeProps['onSaveAs']; +} + +/** + * Returns a badge object suitable for the top nav `badges` prop + * @param onRevert + * @param onSave + * @param onSaveAs + */ +export const getTopNavUnsavedChangesBadge = ({ + onRevert, + onSave, + onSaveAs, +}: TopNavUnsavedChangesBadgeParams): TopNavMenuBadgeProps => { + return { + badgeText: i18n.translate('unsavedChangesBadge.unsavedChangesTitle', { + defaultMessage: 'Unsaved changes', + }), + renderCustomBadge: ({ badgeText }) => ( + + ), + }; +}; diff --git a/packages/kbn-unsaved-changes-badge/tsconfig.json b/packages/kbn-unsaved-changes-badge/tsconfig.json new file mode 100644 index 0000000000000..1c80dd5dae638 --- /dev/null +++ b/packages/kbn-unsaved-changes-badge/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": ["*.ts", "src/**/*", "__mocks__/**/*.ts"], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/i18n", + "@kbn/navigation-plugin", + ] +} diff --git a/packages/kbn-unsaved-changes-badge/types.ts b/packages/kbn-unsaved-changes-badge/types.ts new file mode 100644 index 0000000000000..a2697b8ae7717 --- /dev/null +++ b/packages/kbn-unsaved-changes-badge/types.ts @@ -0,0 +1,10 @@ +/* + * 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 type { UnsavedChangesBadgeProps } from './src/components/unsaved_changes_badge'; +export type { TopNavUnsavedChangesBadgeParams } from './src/utils/get_top_nav_unsaved_changes_badge'; diff --git a/src/plugins/discover/public/application/main/components/layout/__stories__/get_layout_props.ts b/src/plugins/discover/public/application/main/components/layout/__stories__/get_layout_props.ts index d681365767ce8..5a5c2bb039683 100644 --- a/src/plugins/discover/public/application/main/components/layout/__stories__/get_layout_props.ts +++ b/src/plugins/discover/public/application/main/components/layout/__stories__/get_layout_props.ts @@ -86,7 +86,6 @@ const getCommonProps = () => { inspectorAdapters: { requests: new RequestAdapter() }, onChangeDataView: action('change the data view'), onUpdateQuery: action('update the query'), - resetSavedSearch: action('reset the saved search the query'), savedSearch: savedSearchMock, savedSearchRefetch$: new Subject(), searchSource: searchSourceMock, diff --git a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx index 832a37577fc89..9802b25d8fc92 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx @@ -29,7 +29,6 @@ import { Storage } from '@kbn/kibana-utils-plugin/public'; import { createSearchSessionMock } from '../../../../__mocks__/search_session'; import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; import { getSessionServiceMock } from '@kbn/data-plugin/public/search/session/mocks'; -import { ResetSearchButton } from './reset_search_button'; import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; import { DiscoverMainProvider } from '../../services/discover_state_provider'; import { act } from 'react-dom/test-utils'; @@ -162,25 +161,4 @@ describe('Discover histogram layout component', () => { expect(component.isEmptyRender()).toBe(false); }); }); - - describe('reset search button', () => { - it('renders the button when there is a saved search', async () => { - const { component } = await mountComponent(); - expect(component.find(ResetSearchButton).exists()).toBe(true); - }); - - it('does not render the button when there is no saved search', async () => { - const { component } = await mountComponent({ - savedSearch: { ...savedSearchMock, id: undefined }, - }); - expect(component.find(ResetSearchButton).exists()).toBe(false); - }); - - it('should call resetSavedSearch when clicked', async () => { - const { component, stateContainer } = await mountComponent(); - expect(component.find(ResetSearchButton).exists()).toBe(true); - component.find(ResetSearchButton).find('button').simulate('click'); - expect(stateContainer.actions.undoSavedSearchChanges).toHaveBeenCalled(); - }); - }); }); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx index 54447ebe06b06..d6bcd73c94d8e 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx @@ -10,10 +10,8 @@ import React from 'react'; import { UnifiedHistogramContainer } from '@kbn/unified-histogram-plugin/public'; import { css } from '@emotion/react'; import useObservable from 'react-use/lib/useObservable'; -import { useSavedSearchInitial } from '../../services/discover_state_provider'; import { useDiscoverHistogram } from './use_discover_histogram'; import { type DiscoverMainContentProps, DiscoverMainContent } from './discover_main_content'; -import { ResetSearchButton } from './reset_search_button'; import { useAppStateSelector } from '../../services/discover_app_state_container'; export interface DiscoverHistogramLayoutProps extends DiscoverMainContentProps { @@ -32,7 +30,6 @@ export const DiscoverHistogramLayout = ({ ...mainContentProps }: DiscoverHistogramLayoutProps) => { const { dataState } = stateContainer; - const savedSearch = useSavedSearchInitial(); const searchSessionId = useObservable(stateContainer.searchSessionManager.searchSessionId$); const hideChart = useAppStateSelector((state) => state.hideChart); const unifiedHistogramProps = useDiscoverHistogram({ @@ -54,11 +51,6 @@ export const DiscoverHistogramLayout = ({ searchSessionId={searchSessionId} requestAdapter={dataState.inspectorAdapters.requests} container={container} - appendHitsCounter={ - savedSearch.id ? ( - - ) : undefined - } css={histogramLayoutCss} > { - it('should call resetSavedSearch when the button is clicked', () => { - const resetSavedSearch = jest.fn(); - const component = mountWithIntl(); - component.find('button[data-test-subj="resetSavedSearch"]').simulate('click'); - expect(resetSavedSearch).toHaveBeenCalled(); - }); -}); diff --git a/src/plugins/discover/public/application/main/components/layout/reset_search_button.tsx b/src/plugins/discover/public/application/main/components/layout/reset_search_button.tsx deleted file mode 100644 index e9b0cc2417d97..0000000000000 --- a/src/plugins/discover/public/application/main/components/layout/reset_search_button.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 React from 'react'; -import { EuiButtonEmpty, EuiFlexItem } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { css } from '@emotion/react'; - -const resetSearchButtonWrapper = css` - overflow: hidden; -`; - -export const ResetSearchButton = ({ resetSavedSearch }: { resetSavedSearch?: () => void }) => { - return ( - - - - - - ); -}; diff --git a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx index 7b53587d08eb4..51484519ee7ac 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx @@ -6,6 +6,7 @@ * Side Public License, v 1. */ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import useObservable from 'react-use/lib/useObservable'; import type { Query, TimeRange, AggregateQuery } from '@kbn/es-query'; import { DataViewType, type DataView } from '@kbn/data-views-plugin/public'; import type { DataViewPickerProps } from '@kbn/unified-search-plugin/public'; @@ -14,6 +15,7 @@ import { useSavedSearchInitial } from '../../services/discover_state_provider'; import { useInternalStateSelector } from '../../services/discover_internal_state_container'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { getTopNavLinks } from './get_top_nav_links'; +import { getTopNavBadges } from './get_top_nav_badges'; import { getHeaderActionMenuMounter } from '../../../../kibana_services'; import { DiscoverStateContainer } from '../../services/discover_state'; import { onSaveSearch } from './on_save_search'; @@ -115,6 +117,20 @@ export const DiscoverTopNav = ({ const topNavCustomization = useDiscoverCustomization('top_nav'); + const hasSavedSearchChanges = useObservable(stateContainer.savedSearchState.getHasChanged$()); + const hasUnsavedChanges = + hasSavedSearchChanges && Boolean(stateContainer.savedSearchState.getId()); + const topNavBadges = useMemo( + () => + getTopNavBadges({ + stateContainer, + services, + hasUnsavedChanges, + topNavCustomization, + }), + [stateContainer, services, hasUnsavedChanges, topNavCustomization] + ); + const topNavMenu = useMemo( () => getTopNavLinks({ @@ -216,6 +232,7 @@ export const DiscoverTopNav = ({ return ( { + const topNavBadges = getTopNavBadges({ + hasUnsavedChanges: false, + services: discoverServiceMock, + stateContainer, + topNavCustomization: undefined, + }); + expect(topNavBadges).toMatchInlineSnapshot(`Array []`); + }); + + test('should return the unsaved changes badge when has changes', () => { + const topNavBadges = getTopNavBadges({ + hasUnsavedChanges: true, + services: discoverServiceMock, + stateContainer, + topNavCustomization: undefined, + }); + expect(topNavBadges).toMatchInlineSnapshot(` + Array [ + Object { + "badgeText": "Unsaved changes", + "renderCustomBadge": [Function], + }, + ] + `); + }); + + test('should not return the unsaved changes badge when disabled in customization', () => { + const topNavBadges = getTopNavBadges({ + hasUnsavedChanges: true, + services: discoverServiceMock, + stateContainer, + topNavCustomization: { + id: 'top_nav', + defaultBadges: { + unsavedChangesBadge: { + disabled: true, + }, + }, + }, + }); + expect(topNavBadges).toMatchInlineSnapshot(`Array []`); + }); + + test('should allow to render additional badges when customized', () => { + const topNavBadges = getTopNavBadges({ + hasUnsavedChanges: true, + services: discoverServiceMock, + stateContainer, + topNavCustomization: { + id: 'top_nav', + getBadges: () => { + return [ + { + data: { + badgeText: 'test10', + }, + order: 10, + }, + { + data: { + badgeText: 'test200', + }, + order: 200, + }, + ]; + }, + }, + }); + expect(topNavBadges).toMatchInlineSnapshot(` + Array [ + Object { + "badgeText": "test10", + }, + Object { + "badgeText": "Unsaved changes", + "renderCustomBadge": [Function], + }, + Object { + "badgeText": "test200", + }, + ] + `); + }); +}); diff --git a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx new file mode 100644 index 0000000000000..74547544848d9 --- /dev/null +++ b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx @@ -0,0 +1,57 @@ +/* + * 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 type { TopNavMenuBadgeProps } from '@kbn/navigation-plugin/public'; +import { getTopNavUnsavedChangesBadge } from '@kbn/unsaved-changes-badge'; +import { DiscoverStateContainer } from '../../services/discover_state'; +import type { TopNavCustomization } from '../../../../customizations'; +import { onSaveSearch } from './on_save_search'; +import { DiscoverServices } from '../../../../build_services'; + +/** + * Helper function to build the top nav badges + */ +export const getTopNavBadges = ({ + hasUnsavedChanges, + stateContainer, + services, + topNavCustomization, +}: { + hasUnsavedChanges: boolean | undefined; + stateContainer: DiscoverStateContainer; + services: DiscoverServices; + topNavCustomization: TopNavCustomization | undefined; +}): TopNavMenuBadgeProps[] => { + const saveSearch = (initialCopyOnSave?: boolean) => + onSaveSearch({ + initialCopyOnSave, + savedSearch: stateContainer.savedSearchState.getState(), + services, + state: stateContainer, + }); + + const defaultBadges = topNavCustomization?.defaultBadges; + const entries = [...(topNavCustomization?.getBadges?.() ?? [])]; + + if (hasUnsavedChanges && !defaultBadges?.unsavedChangesBadge?.disabled) { + entries.push({ + data: getTopNavUnsavedChangesBadge({ + onRevert: stateContainer.actions.undoSavedSearchChanges, + onSave: async () => { + await saveSearch(); + }, + onSaveAs: async () => { + await saveSearch(true); + }, + }), + order: defaultBadges?.unsavedChangesBadge?.order ?? 100, + }); + } + + return entries.sort((a, b) => a.order - b.order).map((entry) => entry.data); +}; diff --git a/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx b/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx index abae8e83b41a1..30d73a6280072 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx @@ -80,12 +80,14 @@ export async function onSaveSearch({ savedSearch, services, state, + initialCopyOnSave, onClose, onSaveCb, }: { savedSearch: SavedSearch; services: DiscoverServices; state: DiscoverStateContainer; + initialCopyOnSave?: boolean; onClose?: () => void; onSaveCb?: () => void; }) { @@ -173,6 +175,7 @@ export async function onSaveSearch({ services={services} title={savedSearch.title ?? ''} showCopyOnSave={!!savedSearch.id} + initialCopyOnSave={initialCopyOnSave} description={savedSearch.description} timeRestore={savedSearch.timeRestore} tags={savedSearch.tags ?? []} @@ -188,6 +191,7 @@ const SaveSearchObjectModal: React.FC<{ services: DiscoverServices; title: string; showCopyOnSave: boolean; + initialCopyOnSave?: boolean; description?: string; timeRestore?: boolean; tags: string[]; @@ -200,6 +204,7 @@ const SaveSearchObjectModal: React.FC<{ description, tags, showCopyOnSave, + initialCopyOnSave, timeRestore: savedTimeRestore, onSave, onClose, @@ -263,6 +268,7 @@ const SaveSearchObjectModal: React.FC<{ { - // @ts-expect-error - return !isEqual(prevSavedSearch[key], nextSavedSearchWithoutSearchSource[key]); + ] as Array>); + + // at least one change in saved search attributes + const hasChangesInSavedSearch = [...keys].some((key) => { + if ( + ['usesAdHocDataView', 'hideChart'].includes(key) && + typeof prevSavedSearch[key] === 'undefined' && + nextSavedSearchWithoutSearchSource[key] === false + ) { + return false; // ignore when value was changed from `undefined` to `false` as it happens per app logic, not by a user action + } + + const isSame = isEqual(prevSavedSearch[key], nextSavedSearchWithoutSearchSource[key]); + + if (!isSame) { + addLog('[savedSearch] difference between initial and changed version', { + key, + before: prevSavedSearch[key], + after: nextSavedSearchWithoutSearchSource[key], + }); + } + + return !isSame; }); - const searchSourceDiff = - !isEqual(prevSearchSource.getField('filter'), nextSearchSource.getField('filter')) || - !isEqual(prevSearchSource.getField('query'), nextSearchSource.getField('query')) || - !isEqual(prevSearchSource.getField('index'), nextSearchSource.getField('index')); - const hasChanged = Boolean(savedSearchDiff.length || searchSourceDiff); - if (hasChanged) { - addLog('[savedSearch] difference between initial and changed version', searchSourceDiff); + if (hasChangesInSavedSearch) { + return false; } - return !hasChanged; + + // at least one change in search source fields + const hasChangesInSearchSource = ( + ['filter', 'query', 'index'] as Array + ).some((key) => { + const prevValue = + key === 'index' ? prevSearchSource.getField(key)?.id : prevSearchSource.getField(key); + const nextValue = + key === 'index' ? nextSearchSource.getField(key)?.id : nextSearchSource.getField(key); + const isSame = isEqual(prevValue, nextValue); + + if (!isSame) { + addLog('[savedSearch] difference between initial and changed version', { + key, + before: prevValue, + after: nextValue, + }); + } + + return !isSame; + }); + + if (hasChangesInSearchSource) { + return false; + } + + addLog('[savedSearch] no difference between initial and changed version'); + + return true; } diff --git a/src/plugins/discover/public/application/main/services/discover_state.test.ts b/src/plugins/discover/public/application/main/services/discover_state.test.ts index bcd96315ce575..dd2bc17bd891a 100644 --- a/src/plugins/discover/public/application/main/services/discover_state.test.ts +++ b/src/plugins/discover/public/application/main/services/discover_state.test.ts @@ -360,10 +360,16 @@ describe('Test discover state actions', () => { const { searchSource, ...savedSearch } = state.savedSearchState.getState(); expect(savedSearch).toMatchInlineSnapshot(` Object { + "breakdownField": undefined, "columns": Array [ "default_column", ], + "hideAggregatedPreview": undefined, + "hideChart": undefined, "refreshInterval": undefined, + "rowHeight": undefined, + "rowsPerPage": undefined, + "sampleSize": undefined, "sort": Array [], "timeRange": undefined, "usesAdHocDataView": false, diff --git a/src/plugins/discover/public/application/main/services/discover_state.ts b/src/plugins/discover/public/application/main/services/discover_state.ts index bb0fbed792d64..b3abfed5e6ecc 100644 --- a/src/plugins/discover/public/application/main/services/discover_state.ts +++ b/src/plugins/discover/public/application/main/services/discover_state.ts @@ -185,7 +185,7 @@ export interface DiscoverStateContainer { /** * Undo changes made to the saved search, e.g. when the user triggers the "Reset search" button */ - undoSavedSearchChanges: () => void; + undoSavedSearchChanges: () => Promise; /** * When saving a saved search with an ad hoc data view, a new id needs to be generated for the data view * This is to prevent duplicate ids messing with our system diff --git a/src/plugins/discover/public/application/main/utils/update_saved_search.ts b/src/plugins/discover/public/application/main/utils/update_saved_search.ts index dc5e5307f5fe0..fb6ffd6621825 100644 --- a/src/plugins/discover/public/application/main/utils/update_saved_search.ts +++ b/src/plugins/discover/public/application/main/utils/update_saved_search.ts @@ -60,26 +60,17 @@ export function updateSavedSearch({ if (state.grid) { savedSearch.grid = state.grid; } - if (typeof state.hideChart !== 'undefined') { - savedSearch.hideChart = state.hideChart; - } - if (typeof state.rowHeight !== 'undefined') { - savedSearch.rowHeight = state.rowHeight; - } + savedSearch.hideChart = state.hideChart; + savedSearch.rowHeight = state.rowHeight; + savedSearch.rowsPerPage = state.rowsPerPage; + savedSearch.sampleSize = state.sampleSize; if (state.viewMode) { savedSearch.viewMode = state.viewMode; } - if (typeof state.breakdownField !== 'undefined') { - savedSearch.breakdownField = state.breakdownField; - } else if (savedSearch.breakdownField) { - savedSearch.breakdownField = ''; - } - - if (state.hideAggregatedPreview) { - savedSearch.hideAggregatedPreview = state.hideAggregatedPreview; - } + savedSearch.breakdownField = state.breakdownField || undefined; // `undefined` instead of an empty string + savedSearch.hideAggregatedPreview = state.hideAggregatedPreview; // add a flag here to identify text based language queries // these should be filtered out from the visualize editor diff --git a/src/plugins/discover/public/customizations/customization_types/top_nav_customization.ts b/src/plugins/discover/public/customizations/customization_types/top_nav_customization.ts index 4699a4665902f..d57b898b15111 100644 --- a/src/plugins/discover/public/customizations/customization_types/top_nav_customization.ts +++ b/src/plugins/discover/public/customizations/customization_types/top_nav_customization.ts @@ -6,20 +6,20 @@ * Side Public License, v 1. */ -import type { TopNavMenuData } from '@kbn/navigation-plugin/public'; +import type { TopNavMenuData, TopNavMenuBadgeProps } from '@kbn/navigation-plugin/public'; -export interface TopNavDefaultMenuItem { +export interface TopNavDefaultItem { disabled?: boolean; order?: number; } export interface TopNavDefaultMenu { - newItem?: TopNavDefaultMenuItem; - openItem?: TopNavDefaultMenuItem; - shareItem?: TopNavDefaultMenuItem; - alertsItem?: TopNavDefaultMenuItem; - inspectItem?: TopNavDefaultMenuItem; - saveItem?: TopNavDefaultMenuItem; + newItem?: TopNavDefaultItem; + openItem?: TopNavDefaultItem; + shareItem?: TopNavDefaultItem; + alertsItem?: TopNavDefaultItem; + inspectItem?: TopNavDefaultItem; + saveItem?: TopNavDefaultItem; } export interface TopNavMenuItem { @@ -27,8 +27,19 @@ export interface TopNavMenuItem { order: number; } +export interface TopNavDefaultBadges { + unsavedChangesBadge?: TopNavDefaultItem; +} + +export interface TopNavBadge { + data: TopNavMenuBadgeProps; + order: number; +} + export interface TopNavCustomization { id: 'top_nav'; defaultMenu?: TopNavDefaultMenu; getMenuItems?: () => TopNavMenuItem[]; + defaultBadges?: TopNavDefaultBadges; + getBadges?: () => TopNavBadge[]; } diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index 6fca69dfaea5c..0a3d7e0947137 100644 --- a/src/plugins/discover/tsconfig.json +++ b/src/plugins/discover/tsconfig.json @@ -74,6 +74,7 @@ "@kbn/rule-data-utils", "@kbn/global-search-plugin", "@kbn/resizable-layout", + "@kbn/unsaved-changes-badge", "@kbn/core-chrome-browser" ], "exclude": [ diff --git a/src/plugins/navigation/public/index.ts b/src/plugins/navigation/public/index.ts index 904e4ed2c5e7c..7db4526995c04 100644 --- a/src/plugins/navigation/public/index.ts +++ b/src/plugins/navigation/public/index.ts @@ -13,7 +13,7 @@ export function plugin(initializerContext: PluginInitializerContext) { return new NavigationPublicPlugin(initializerContext); } -export type { TopNavMenuData, TopNavMenuProps } from './top_nav_menu'; +export type { TopNavMenuData, TopNavMenuProps, TopNavMenuBadgeProps } from './top_nav_menu'; export { TopNavMenu } from './top_nav_menu'; export type { NavigationPublicPluginSetup, NavigationPublicPluginStart } from './types'; diff --git a/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu.test.tsx.snap b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu.test.tsx.snap new file mode 100644 index 0000000000000..08bcceb4c8116 --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu.test.tsx.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TopNavMenu when setMenuMountPoint is provided should render badges and search bar 1`] = ` +
+ + + + badge1 + + + + + + + + badge2 + + + + +
+ badge3 +
+
+`; diff --git a/src/plugins/navigation/public/top_nav_menu/index.ts b/src/plugins/navigation/public/top_nav_menu/index.ts index 16c92aaaaf712..e6705273c2a1b 100644 --- a/src/plugins/navigation/public/top_nav_menu/index.ts +++ b/src/plugins/navigation/public/top_nav_menu/index.ts @@ -7,7 +7,7 @@ */ export { createTopNav } from './create_top_nav_menu'; -export type { TopNavMenuProps } from './top_nav_menu'; +export type { TopNavMenuProps, TopNavMenuBadgeProps } from './top_nav_menu'; export { TopNavMenu } from './top_nav_menu'; export type { TopNavMenuData } from './top_nav_menu_data'; export type { TopNavMenuExtensionsRegistrySetup } from './top_nav_menu_extensions_registry'; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx index 127bd6e8482b1..218c620519cf2 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx @@ -10,10 +10,11 @@ import React from 'react'; import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { MountPoint } from '@kbn/core/public'; -import { TopNavMenu } from './top_nav_menu'; +import { TopNavMenu, TopNavMenuBadgeProps } from './top_nav_menu'; import { TopNavMenuData } from './top_nav_menu_data'; import { shallowWithIntl, mountWithIntl } from '@kbn/test-jest-helpers'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import { EuiToolTipProps } from '@elastic/eui'; const unifiedSearch = { ui: { @@ -24,6 +25,7 @@ const unifiedSearch = { describe('TopNavMenu', () => { const WRAPPER_SELECTOR = '.kbnTopNavMenu__wrapper'; + const BADGES_GROUP_SELECTOR = '.kbnTopNavMenu__badgeGroup'; const TOP_NAV_ITEM_SELECTOR = 'TopNavMenuItem'; const SEARCH_BAR_SELECTOR = 'AggregateQuerySearchBar'; const menuItems: TopNavMenuData[] = [ @@ -43,6 +45,25 @@ describe('TopNavMenu', () => { run: jest.fn(), }, ]; + const badges: TopNavMenuBadgeProps[] = [ + { + badgeText: 'badge1', + }, + { + 'data-test-subj': 'test2', + badgeText: 'badge2', + title: '', + color: 'warning', + toolTipProps: { + content: 'tooltip content', + position: 'bottom', + } as EuiToolTipProps, + }, + { + badgeText: 'badge3', + renderCustomBadge: ({ badgeText }) =>
{badgeText}
, + }, + ]; it('Should render nothing when no config is provided', () => { const component = shallowWithIntl(); @@ -165,5 +186,26 @@ describe('TopNavMenu', () => { // menu is rendered outside of the component expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); }); + + it('should render badges and search bar', async () => { + const component = mountWithIntl( + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(1); + expect(portalTarget.querySelector(BADGES_GROUP_SELECTOR)).toMatchSnapshot(); + }); }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index de060db9b6e3b..9c899cd9d7207 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { ReactElement } from 'react'; +import React, { ReactElement, Fragment } from 'react'; import { EuiBadge, EuiBadgeGroup, @@ -25,9 +25,10 @@ import { AggregateQuery, Query } from '@kbn/es-query'; import { TopNavMenuData } from './top_nav_menu_data'; import { TopNavMenuItem } from './top_nav_menu_item'; -type Badge = EuiBadgeProps & { +export type TopNavMenuBadgeProps = EuiBadgeProps & { badgeText: string; toolTipProps?: Partial; + renderCustomBadge?: (props: { badgeText: string }) => ReactElement; }; export type TopNavMenuProps = Omit< @@ -35,7 +36,7 @@ export type TopNavMenuProps = Omit< 'kibana' | 'intl' | 'timeHistory' > & { config?: TopNavMenuData[]; - badges?: Badge[]; + badges?: TopNavMenuBadgeProps[]; showSearchBar?: boolean; showQueryInput?: boolean; showDatePicker?: boolean; @@ -82,14 +83,22 @@ export function TopNavMenu( return null; } - function createBadge({ badgeText, toolTipProps, ...badgeProps }: Badge, i: number): ReactElement { - const Badge = ({ key, ...rest }: { key?: string }) => ( - + function createBadge( + { badgeText, toolTipProps, renderCustomBadge, ...badgeProps }: TopNavMenuBadgeProps, + i: number + ): ReactElement { + const key = `nav-menu-badge-${i}`; + + const Badge = () => ( + {badgeText} ); - const key = `nav-menu-badge-${i}`; + if (renderCustomBadge) { + return {renderCustomBadge({ badgeText })}; + } + return toolTipProps ? ( diff --git a/test/functional/apps/discover/group1/_discover.ts b/test/functional/apps/discover/group1/_discover.ts index 63f9109ba8bd2..a041939f2b809 100644 --- a/test/functional/apps/discover/group1/_discover.ts +++ b/test/functional/apps/discover/group1/_discover.ts @@ -138,7 +138,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // reset to persisted state await queryBar.clearQuery(); - await PageObjects.discover.clickResetSavedSearchButton(); + await PageObjects.discover.revertUnsavedChanges(); const expectedHitCount = '14,004'; await retry.try(async function () { expect(await queryBar.getQueryString()).to.be(''); diff --git a/test/functional/apps/discover/group1/_discover_histogram.ts b/test/functional/apps/discover/group1/_discover_histogram.ts index d69bd153b3d56..43f39b417864c 100644 --- a/test/functional/apps/discover/group1/_discover_histogram.ts +++ b/test/functional/apps/discover/group1/_discover_histogram.ts @@ -320,7 +320,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await PageObjects.discover.toggleChartVisibility(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await PageObjects.discover.clickResetSavedSearchButton(); + await PageObjects.discover.revertUnsavedChanges(); await PageObjects.discover.waitUntilSearchingHasFinished(); requestData = await testSubjects.getAttribute('unifiedHistogramChart', 'data-request-data'); expect(JSON.parse(requestData)).to.eql({ diff --git a/test/functional/apps/discover/group3/_request_counts.ts b/test/functional/apps/discover/group3/_request_counts.ts index fdee64ada9965..a1038b3f7e4ee 100644 --- a/test/functional/apps/discover/group3/_request_counts.ts +++ b/test/functional/apps/discover/group3/_request_counts.ts @@ -156,7 +156,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await queryBar.clickQuerySubmitButton(); await waitForLoadingToFinish(); await expectSearches(type, 2, async () => { - await PageObjects.discover.clickResetSavedSearchButton(); + await PageObjects.discover.revertUnsavedChanges(); }); // clearing the saved search await expectSearches('ese', 2, async () => { diff --git a/test/functional/apps/discover/group3/_unsaved_changes_badge.ts b/test/functional/apps/discover/group3/_unsaved_changes_badge.ts new file mode 100644 index 0000000000000..292835b37ef10 --- /dev/null +++ b/test/functional/apps/discover/group3/_unsaved_changes_badge.ts @@ -0,0 +1,167 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +const SAVED_SEARCH_NAME = 'test saved search'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const testSubjects = getService('testSubjects'); + const dataGrid = getService('dataGrid'); + const PageObjects = getPageObjects([ + 'settings', + 'common', + 'discover', + 'header', + 'timePicker', + 'dashboard', + 'unifiedFieldList', + ]); + const security = getService('security'); + const defaultSettings = { + defaultIndex: 'logstash-*', + hideAnnouncements: true, + }; + + describe('discover unsaved changes badge', function describeIndexTests() { + before(async () => { + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover'); + }); + + after(async () => { + await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover'); + await kibanaServer.uiSettings.replace({}); + await kibanaServer.savedObjects.cleanStandardList(); + }); + + beforeEach(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + }); + + it('should not show the badge initially nor after changes to a draft saved search', async () => { + await testSubjects.missingOrFail('unsavedChangesBadge'); + + await PageObjects.unifiedFieldList.clickFieldListItemAdd('bytes'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + }); + + it('should show the badge only after changes to a persisted saved search', async () => { + await PageObjects.discover.saveSearch(SAVED_SEARCH_NAME); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + + await PageObjects.unifiedFieldList.clickFieldListItemAdd('bytes'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.existOrFail('unsavedChangesBadge'); + + await PageObjects.discover.saveUnsavedChanges(); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + }); + + it('should not show a badge after loading a saved search, only after changes', async () => { + await PageObjects.discover.loadSavedSearch(SAVED_SEARCH_NAME); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + + await PageObjects.discover.chooseBreakdownField('_index'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.existOrFail('unsavedChangesBadge'); + }); + + it('should allow to revert changes', async () => { + await PageObjects.discover.loadSavedSearch(SAVED_SEARCH_NAME); + await testSubjects.missingOrFail('unsavedChangesBadge'); + + // test changes to columns + expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes']); + await PageObjects.unifiedFieldList.clickFieldListItemAdd('extension'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes', 'extension']); + await testSubjects.existOrFail('unsavedChangesBadge'); + await PageObjects.discover.revertUnsavedChanges(); + expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes']); + await testSubjects.missingOrFail('unsavedChangesBadge'); + + // test changes to sample size + await dataGrid.clickGridSettings(); + expect(await dataGrid.getCurrentSampleSizeValue()).to.be(500); + await dataGrid.changeSampleSizeValue(250); + await dataGrid.clickGridSettings(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await testSubjects.existOrFail('unsavedChangesBadge'); + await dataGrid.clickGridSettings(); + expect(await dataGrid.getCurrentSampleSizeValue()).to.be(250); + await dataGrid.clickGridSettings(); + await PageObjects.discover.revertUnsavedChanges(); + await testSubjects.missingOrFail('unsavedChangesBadge'); + await dataGrid.clickGridSettings(); + expect(await dataGrid.getCurrentSampleSizeValue()).to.be(500); + await dataGrid.clickGridSettings(); + + // test changes to rows per page + await dataGrid.checkCurrentRowsPerPageToBe(100); + await dataGrid.changeRowsPerPageTo(25); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await testSubjects.existOrFail('unsavedChangesBadge'); + await dataGrid.checkCurrentRowsPerPageToBe(25); + await PageObjects.discover.revertUnsavedChanges(); + await testSubjects.missingOrFail('unsavedChangesBadge'); + await dataGrid.checkCurrentRowsPerPageToBe(100); + }); + + it('should hide the badge once user manually reverts changes', async () => { + await PageObjects.discover.loadSavedSearch(SAVED_SEARCH_NAME); + await testSubjects.missingOrFail('unsavedChangesBadge'); + + // changes to columns + expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes']); + await PageObjects.unifiedFieldList.clickFieldListItemAdd('extension'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes', 'extension']); + await testSubjects.existOrFail('unsavedChangesBadge'); + await PageObjects.unifiedFieldList.clickFieldListItemRemove('extension'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes']); + await testSubjects.missingOrFail('unsavedChangesBadge'); + + // test changes to breakdown field + await PageObjects.discover.chooseBreakdownField('_index'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await testSubjects.existOrFail('unsavedChangesBadge'); + await PageObjects.discover.clearBreakdownField(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await testSubjects.missingOrFail('unsavedChangesBadge'); + }); + }); +} diff --git a/test/functional/apps/discover/group3/index.ts b/test/functional/apps/discover/group3/index.ts index 5827c1e7ed805..4ac4572014a52 100644 --- a/test/functional/apps/discover/group3/index.ts +++ b/test/functional/apps/discover/group3/index.ts @@ -25,5 +25,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_request_counts')); loadTestFile(require.resolve('./_doc_viewer')); loadTestFile(require.resolve('./_view_mode_toggle')); + loadTestFile(require.resolve('./_unsaved_changes_badge')); }); } diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 47a79132212cc..6005bbd2de7ad 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -168,10 +168,30 @@ export class DiscoverPageObject extends FtrService { await this.testSubjects.click('discoverOpenButton'); } - public async clickResetSavedSearchButton() { - await this.testSubjects.moveMouseTo('resetSavedSearch'); - await this.testSubjects.click('resetSavedSearch'); + public async revertUnsavedChanges() { + await this.testSubjects.moveMouseTo('unsavedChangesBadge'); + await this.testSubjects.click('unsavedChangesBadge'); + await this.retry.waitFor('popover is open', async () => { + return Boolean(await this.testSubjects.find('unsavedChangesBadgeMenuPanel')); + }); + await this.testSubjects.click('revertUnsavedChangesButton'); await this.header.waitUntilLoadingHasFinished(); + await this.waitUntilSearchingHasFinished(); + } + + public async saveUnsavedChanges() { + await this.testSubjects.moveMouseTo('unsavedChangesBadge'); + await this.testSubjects.click('unsavedChangesBadge'); + await this.retry.waitFor('popover is open', async () => { + return Boolean(await this.testSubjects.find('unsavedChangesBadgeMenuPanel')); + }); + await this.testSubjects.click('saveUnsavedChangesButton'); + await this.retry.waitFor('modal is open', async () => { + return Boolean(await this.testSubjects.find('confirmSaveSavedObjectButton')); + }); + await this.testSubjects.click('confirmSaveSavedObjectButton'); + await this.header.waitUntilLoadingHasFinished(); + await this.waitUntilSearchingHasFinished(); } public async closeLoadSavedSearchPanel() { @@ -199,6 +219,10 @@ export class DiscoverPageObject extends FtrService { await this.comboBox.set('unifiedHistogramBreakdownFieldSelector', field); } + public async clearBreakdownField() { + await this.comboBox.clear('unifiedHistogramBreakdownFieldSelector'); + } + public async chooseLensChart(chart: string) { await this.comboBox.set('unifiedHistogramSuggestionSelector', chart); } diff --git a/tsconfig.base.json b/tsconfig.base.json index c367ace533937..7faad08e28f0d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1572,6 +1572,8 @@ "@kbn/unified-histogram-plugin/*": ["src/plugins/unified_histogram/*"], "@kbn/unified-search-plugin": ["src/plugins/unified_search"], "@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/upgrade-assistant-plugin": ["x-pack/plugins/upgrade_assistant"], "@kbn/upgrade-assistant-plugin/*": ["x-pack/plugins/upgrade_assistant/*"], "@kbn/uptime-plugin": ["x-pack/plugins/uptime"], diff --git a/x-pack/plugins/log_explorer/public/customizations/log_explorer_profile.tsx b/x-pack/plugins/log_explorer/public/customizations/log_explorer_profile.tsx index e5fb09d56e5fa..bf3913dff5680 100644 --- a/x-pack/plugins/log_explorer/public/customizations/log_explorer_profile.tsx +++ b/x-pack/plugins/log_explorer/public/customizations/log_explorer_profile.tsx @@ -113,6 +113,9 @@ export const createLogExplorerProfileCustomizations = openItem: { disabled: true }, saveItem: { disabled: true }, }, + defaultBadges: { + unsavedChangesBadge: { disabled: true }, + }, }); /** diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 2ec1a295cc065..ad8ed89bb8061 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -2455,7 +2455,6 @@ "discover.notifications.notSavedSearchTitle": "La recherche \"{savedSearchTitle}\" n'a pas été enregistrée.", "discover.notifications.savedSearchTitle": "La recherche \"{savedSearchTitle}\" a été enregistrée.", "discover.pageTitleWithoutSavedSearch": "Discover - Recherche non encore enregistrée", - "discover.reloadSavedSearchButton": "Réinitialiser la recherche", "discover.rootBreadcrumb": "Découverte", "discover.sampleData.viewLinkLabel": "Découverte", "discover.savedSearch.savedObjectName": "Recherche enregistrée", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 626dabd7c69e6..f529bae21b70e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2469,7 +2469,6 @@ "discover.notifications.notSavedSearchTitle": "検索「{savedSearchTitle}」は保存されませんでした。", "discover.notifications.savedSearchTitle": "検索「{savedSearchTitle}」が保存されました。", "discover.pageTitleWithoutSavedSearch": "Discover - 検索は保存されていません", - "discover.reloadSavedSearchButton": "検索をリセット", "discover.rootBreadcrumb": "Discover", "discover.sampleData.viewLinkLabel": "Discover", "discover.savedSearch.savedObjectName": "保存検索", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6c9c7e2d37e7f..7ef6acf6b7b43 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2469,7 +2469,6 @@ "discover.notifications.notSavedSearchTitle": "搜索“{savedSearchTitle}”未保存。", "discover.notifications.savedSearchTitle": "搜索“{savedSearchTitle}”已保存", "discover.pageTitleWithoutSavedSearch": "Discover - 尚未保存搜索", - "discover.reloadSavedSearchButton": "重置搜索", "discover.rootBreadcrumb": "Discover", "discover.sampleData.viewLinkLabel": "Discover", "discover.savedSearch.savedObjectName": "已保存搜索", diff --git a/yarn.lock b/yarn.lock index dbf3facc5ec10..d1c704d5622c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6048,6 +6048,10 @@ version "0.0.0" uid "" +"@kbn/unsaved-changes-badge@link:packages/kbn-unsaved-changes-badge": + version "0.0.0" + uid "" + "@kbn/upgrade-assistant-plugin@link:x-pack/plugins/upgrade_assistant": version "0.0.0" uid ""