From 5afcc9795343c837b8ffa0fd7aeb2976af368da9 Mon Sep 17 00:00:00 2001 From: mbondyra Date: Wed, 3 Dec 2025 10:27:33 +0100 Subject: [PATCH 1/7] [CPS] Discover + embeddable --- .../check_registered_types.test.ts | 6 +- .../interfaces/fetch/fetch.test.ts | 99 +++++++- .../interfaces/fetch/fetch.ts | 13 +- .../customize_panel_action/constants.ts | 1 + .../cps_usage_overrides_badge.tsx | 215 ++++++++++++++++++ .../customize_panel_action/index.ts | 1 + .../public/panel_actions/register_actions.ts | 7 + .../public/panel_component/panel_module.ts | 1 + .../plugins/shared/discover/kibana.jsonc | 3 +- src/platform/plugins/shared/discover/moon.yml | 1 + .../public/__mocks__/discover_state.mock.ts | 2 + .../on_save_discover_session.test.tsx | 6 + .../on_save_discover_session.tsx | 4 + .../project_routing_switch.test.tsx | 72 ++++++ .../project_routing_switch.tsx | 38 ++++ .../save_discover_session/save_modal.tsx | 29 ++- .../main/data_fetching/fetch_all.ts | 6 +- .../data_fetching/update_search_source.ts | 8 +- .../state_management/discover_state.test.ts | 8 + .../main/state_management/discover_state.ts | 24 ++ .../redux/actions/reset_discover_session.ts | 14 ++ .../actions/save_discover_session.test.ts | 5 + .../redux/actions/save_discover_session.ts | 10 + .../state_management/redux/internal_state.ts | 31 +++ .../state_management/redux/selectors/index.ts | 8 +- .../state_management/redux/selectors/tabs.ts | 2 + .../redux/selectors/unsaved_changes.ts | 14 +- .../main/state_management/redux/types.ts | 3 +- .../shared/discover/public/build_services.ts | 3 + .../initialize_search_embeddable_api.tsx | 8 + .../discover/public/embeddable/types.ts | 2 + .../utils/serialization_utils.test.ts | 125 ++++++++-- .../embeddable/utils/serialization_utils.ts | 14 +- .../plugins/shared/discover/public/types.ts | 2 + .../plugins/shared/discover/tsconfig.json | 3 +- .../saved_object_save_modal_origin.tsx | 55 ++++- .../common/saved_searches_utils.ts | 4 +- .../common/service/get_discover_session.ts | 3 + .../common/service/saved_searches_utils.ts | 1 + .../shared/saved_search/common/types.ts | 6 +- .../public/service/save_discover_session.ts | 19 +- .../saved_search_storage.ts | 1 + .../schema/v1/cm_services.ts | 8 +- .../server/saved_objects/schema.ts | 30 ++- .../server/saved_objects/search.ts | 11 + .../translations/translations/de-DE.json | 1 - .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 49 files changed, 867 insertions(+), 63 deletions(-) create mode 100644 src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/cps_usage_overrides_badge.tsx create mode 100644 src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/project_routing_switch.test.tsx create mode 100644 src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/project_routing_switch.tsx diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 66b120f5bc9d6..3f228cf27fab3 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -163,7 +163,7 @@ describe('checking migration metadata changes on all registered SO types', () => "risk-engine-configuration": "9d54f733fb2bd08978c7059d71e77741574dc2616823745501742d34816a408c", "rules-settings": "436a36535b5d57ea1f7cbaaa37887ed5ddac8c3dea30c9fd98b3931ae87dfe1a", "sample-data-telemetry": "4c102e89bdcaee1ccc887d1709c7e176c05f25b4c5ac14c3d013b58fbfd806ac", - "search": "431e34cbf3aadc050c4f5e23e2e13da977d1d37468f3651499d722c207723c4c", + "search": "876ae1abc39d4026e854de185bbec72bf630bac33541de292c42fc8abf9df4ab", "search-session": "95e62da1c06afde503c7d12efebc7df2102b9cb8bead1f50ca5c6ab8f4b67c26", "search-telemetry": "c152fc7e66d5ac7907e81c0926be9c219a15181e10b418b2fbb86bab2760627c", "search_playground": "97895cb5356dd7dad771b6d72b5c236c7c229c012545d56bf6e849984512c9b1", @@ -1026,8 +1026,8 @@ describe('checking migration metadata changes on all registered SO types', () => "search|global: ce649a79d99c5ff5eb68d544635428ef87946d84", "search|mappings: 432d4dfdb5a33ce29d00ccdcfcda70d7c5f94b52", "search|schemas: 8d6477e08dfdf20335752a69994646f9da90741f", - "search|10.9.0: 557d8a40f3cd758fb4da9afba44e827a8c18b63ba140af871cf4a815f8e5e869", - "search|10.8.0: 76274f35cc139d5e208236bb92c859dd29e27ade181950a9f0bc3e95220c86dc", + "search|10.9.0: 479252675efede136671875406da242ba00df105c02fcab784c22c241bc2e152", + "search|10.8.0: 6f23e64134fc13f4b749afdff5ab636791d9ce58eb585c361fd70a1619915048", "search|10.7.0: 03bcc899ac7be8e0a88520ae8fc091fc6ea37b231848dcbc0119b7425f36dd0e", "search|10.6.0: 7b3028dd2f4dac78ce93be51bb9b85f1b44bdd70181d078fd1867ef5366bb76c", "search|10.5.0: 9ca367bf4f8f09dc59bd48d8fd109ee8db2426305e44aee7e35fed09001bf2dc", diff --git a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/fetch.test.ts b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/fetch.test.ts index 1fd91e178e973..e820e4c2d4120 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/fetch.test.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/fetch.test.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; +import type { AggregateQuery, Filter, ProjectRouting, Query, TimeRange } from '@kbn/es-query'; import { waitFor } from '@testing-library/react'; import { BehaviorSubject, Subject, skip } from 'rxjs'; import { fetch$ } from './fetch'; @@ -376,5 +376,102 @@ describe('onFetchContextChanged', () => { subscription.unsubscribe(); }); }); + + describe('local and parent project routing', () => { + const api = { + parentApi: { + ...parentApi, + projectRouting$: new BehaviorSubject('ALL'), + }, + projectRouting$: new BehaviorSubject('_alias:_origin'), + }; + + test('should emit on subscribe (projectRouting is local projectRouting)', async () => { + const subscription = fetch$(api).subscribe(onFetchMock); + await waitFor(() => { + expect(onFetchMock).toHaveBeenCalledTimes(1); + }); + const fetchContext = onFetchMock.mock.calls[0][0]; + expect(fetchContext.projectRouting).toEqual('_alias:_origin'); + subscription.unsubscribe(); + }); + + test('should emit once on local project routing change', async () => { + const subscription = fetch$(api).pipe(skip(1)).subscribe(onFetchMock); + await waitForSearchSession(); + + api.projectRouting$.next('project1'); + await waitFor(() => { + expect(onFetchMock).toHaveBeenCalledTimes(1); + }); + + const fetchContext = onFetchMock.mock.calls[0][0]; + expect(fetchContext.projectRouting).toEqual('project1'); + subscription.unsubscribe(); + }); + + test('should not emit on parent project routing change', async () => { + const subscription = fetch$(api).pipe(skip(1)).subscribe(onFetchMock); + await waitForSearchSession(); + expect(onFetchMock).not.toHaveBeenCalled(); + + api.parentApi.projectRouting$.next('project2'); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(onFetchMock).not.toHaveBeenCalled(); + subscription.unsubscribe(); + }); + + test('should emit once when local project routing is cleared (projectRouting is parent projectRouting)', async () => { + // Reset parent projectRouting to 'ALL' in case previous test changed it + api.parentApi.projectRouting$.next('ALL'); + api.projectRouting$.next('_alias:_origin'); // Reset local to initial value + + const subscription = fetch$(api).pipe(skip(1)).subscribe(onFetchMock); + await waitForSearchSession(); + expect(onFetchMock).not.toHaveBeenCalled(); + + api.projectRouting$.next(undefined); + await waitFor(() => { + expect(onFetchMock).toHaveBeenCalledTimes(1); + }); + const fetchContext = onFetchMock.mock.calls[0][0]; + expect(fetchContext.projectRouting).toEqual('ALL'); + subscription.unsubscribe(); + }); + }); + + describe('only parent project routing', () => { + const api = { + parentApi: { + ...parentApi, + projectRouting$: new BehaviorSubject('ALL'), + }, + }; + + test('should emit on subscribe (projectRouting is parent projectRouting)', async () => { + const subscription = fetch$(api).subscribe(onFetchMock); + await waitFor(() => { + expect(onFetchMock).toHaveBeenCalledTimes(1); + }); + const fetchContext = onFetchMock.mock.calls[0][0]; + expect(fetchContext.projectRouting).toEqual('ALL'); + subscription.unsubscribe(); + }); + + test('should emit once on parent project routing change', async () => { + const subscription = fetch$(api).pipe(skip(1)).subscribe(onFetchMock); + await waitForSearchSession(); + expect(onFetchMock).not.toHaveBeenCalled(); + + api.parentApi.projectRouting$.next('_alias:_origin'); + + await waitFor(() => { + expect(onFetchMock).toHaveBeenCalledTimes(1); + }); + const fetchContext = onFetchMock.mock.calls[0][0]; + expect(fetchContext.projectRouting).toEqual('_alias:_origin'); + subscription.unsubscribe(); + }); + }); }); }); diff --git a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/fetch.ts b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/fetch.ts index b50b8fdfea72b..f3bb53f33cec2 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/fetch.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/fetch.ts @@ -61,9 +61,16 @@ function getFetchContext$(api: unknown): Observable local ?? parent), + distinctUntilChanged() + ); observables.timeRange = combineLatest({ local: apiPublishesTimeRange(api) ? api.timeRange$ : of(undefined), diff --git a/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/constants.ts b/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/constants.ts index bc8bc08721957..b6b188d018fc3 100644 --- a/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/constants.ts +++ b/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/constants.ts @@ -9,3 +9,4 @@ export const ACTION_CUSTOMIZE_PANEL = 'ACTION_CUSTOMIZE_PANEL'; export const CUSTOM_TIME_RANGE_BADGE = 'CUSTOM_TIME_RANGE_BADGE'; +export const CPS_USAGE_OVERRIDES_BADGE = 'CPS_USAGE_OVERRIDES_BADGE'; diff --git a/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/cps_usage_overrides_badge.tsx b/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/cps_usage_overrides_badge.tsx new file mode 100644 index 0000000000000..79d7ff93d6b35 --- /dev/null +++ b/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/cps_usage_overrides_badge.tsx @@ -0,0 +1,215 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { + Action, + ActionExecutionMeta, + FrequentCompatibilityChangeAction, +} from '@kbn/ui-actions-plugin/public'; +import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; +import React, { useState } from 'react'; +import { EuiPopover, EuiText, EuiButton, EuiSpacer } from '@elastic/eui'; + +import type { EmbeddableApiContext } from '@kbn/presentation-publishing'; +import { apiPublishesProjectRouting, apiHasParentApi } from '@kbn/presentation-publishing'; +import { combineLatest, map } from 'rxjs'; +import { i18n } from '@kbn/i18n'; +import { CPS_USAGE_OVERRIDES_BADGE } from './constants'; +import { uiActions, core } from '../../kibana_services'; +import { ACTION_EDIT_PANEL } from '../edit_panel_action/constants'; +import { CONTEXT_MENU_TRIGGER } from '../triggers'; + +export class CpsUsageOverridesBadge + implements Action, FrequentCompatibilityChangeAction +{ + public readonly type = CPS_USAGE_OVERRIDES_BADGE; + public readonly id = CPS_USAGE_OVERRIDES_BADGE; + public order = 8; + + public getDisplayName({ embeddable }: EmbeddableApiContext) { + if (!this.hasOverride(embeddable)) throw new IncompatibleActionError(); + + if (!apiPublishesProjectRouting(embeddable)) { + throw new IncompatibleActionError(); + } + + const overrideValue = embeddable.projectRouting$.value; + + return i18n.translate('presentationPanel.badge.cpsUsageOverrides.displayName', { + defaultMessage: 'This panel overrides the dashboard CPS scope with: {value}', + values: { + value: overrideValue, + }, + }); + } + + public readonly MenuItem = ({ context }: { context: EmbeddableApiContext }) => { + const { embeddable } = context; + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + if (!this.hasOverride(embeddable)) throw new IncompatibleActionError(); + + if (!apiPublishesProjectRouting(embeddable)) { + throw new IncompatibleActionError(); + } + + const overrideValue = embeddable.projectRouting$.value; + const dashboardValue = + apiHasParentApi(embeddable) && apiPublishesProjectRouting(embeddable.parentApi) + ? embeddable.parentApi.projectRouting$.value + : undefined; + + const badgeLabel = i18n.translate('presentationPanel.badge.cpsUsageOverrides.label', { + defaultMessage: 'CPS overrides', + }); + + const formatProjectRoutingValue = (value: string | undefined) => { + if (value === 'ALL') { + return i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.allProjects', { + defaultMessage: 'All projects', + }); + } + return value; + }; + + const handleEditClick = async () => { + setIsPopoverOpen(false); + try { + const action = await uiActions.getAction(ACTION_EDIT_PANEL); + if (action) { + await action.execute({ + ...context, + trigger: { id: CONTEXT_MENU_TRIGGER }, + }); + } + } catch (error) { + core.notifications.toasts.addError(error, { + title: i18n.translate('presentationPanel.badge.cpsUsageOverrides.editError', { + defaultMessage: 'Failed to open panel configuration', + }), + }); + } + }; + + return ( + setIsPopoverOpen(!isPopoverOpen)} + style={{ cursor: 'pointer' }} + data-test-subj="cpsUsageOverridesBadgeButton" + > + {badgeLabel} + + } + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + anchorPosition="downCenter" + > + +

+ + {i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.title', { + defaultMessage: 'CPS Scope Override', + })} + +

+

+ + {i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.panelScope', { + defaultMessage: 'Panel scope:', + })} + + {formatProjectRoutingValue(overrideValue)} +
+ + {i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.dashboardScope', { + defaultMessage: 'Dashboard scope:', + })} + {' '} + {formatProjectRoutingValue(dashboardValue) ?? + i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.notSet', { + defaultMessage: 'Not set', + })} +

+

+ {i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.description', { + defaultMessage: + "To use the dashboard's CPS scope, remove the override from panel settings.", + })} +

+
+ + + {i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.editButton', { + defaultMessage: 'Edit panel configuration', + })} + +
+ ); + }; + + public couldBecomeCompatible({ embeddable }: EmbeddableApiContext) { + return ( + apiPublishesProjectRouting(embeddable) && + apiHasParentApi(embeddable) && + apiPublishesProjectRouting(embeddable.parentApi) + ); + } + + public getCompatibilityChangesSubject({ embeddable }: EmbeddableApiContext) { + if ( + apiPublishesProjectRouting(embeddable) && + apiHasParentApi(embeddable) && + apiPublishesProjectRouting(embeddable.parentApi) + ) { + return combineLatest([embeddable.projectRouting$, embeddable.parentApi.projectRouting$]).pipe( + map(() => undefined) + ); + } + return undefined; + } + + public async execute(_context: ActionExecutionMeta & EmbeddableApiContext) { + // Badge is informational only - clicking shows tooltip but doesn't navigate + return; + } + + public getIconType() { + return 'beaker'; + } + + public async isCompatible({ embeddable }: EmbeddableApiContext) { + return this.hasOverride(embeddable); + } + + private hasOverride(embeddable: unknown): boolean { + if ( + !apiPublishesProjectRouting(embeddable) || + !apiHasParentApi(embeddable) || + !apiPublishesProjectRouting(embeddable.parentApi) + ) { + return false; + } + + const embeddableProjectRouting = embeddable.projectRouting$.value; + const parentProjectRouting = embeddable.parentApi.projectRouting$.value; + + // Only show badge if embeddable has an explicit (non-undefined) override that differs from dashboard + return ( + embeddableProjectRouting !== undefined && embeddableProjectRouting !== parentProjectRouting + ); + } +} diff --git a/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/index.ts b/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/index.ts index 9440bac5eac11..b35997ce4790b 100644 --- a/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/index.ts +++ b/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/index.ts @@ -9,3 +9,4 @@ export * from './customize_panel_action'; export * from './custom_time_range_badge'; +export * from './cps_usage_overrides_badge'; diff --git a/src/platform/plugins/private/presentation_panel/public/panel_actions/register_actions.ts b/src/platform/plugins/private/presentation_panel/public/panel_actions/register_actions.ts index 77a2d6a64392e..262b2eddfcb7a 100644 --- a/src/platform/plugins/private/presentation_panel/public/panel_actions/register_actions.ts +++ b/src/platform/plugins/private/presentation_panel/public/panel_actions/register_actions.ts @@ -14,6 +14,7 @@ import { ACTION_REMOVE_PANEL } from './remove_panel_action/constants'; import { ACTION_CUSTOMIZE_PANEL, CUSTOM_TIME_RANGE_BADGE, + CPS_USAGE_OVERRIDES_BADGE, } from './customize_panel_action/constants'; import { CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER } from './triggers'; import { ACTION_SHOW_CONFIG_PANEL } from './show_config_panel_action/constants'; @@ -31,6 +32,12 @@ export const registerActions = () => { }); uiActions.attachAction(PANEL_BADGE_TRIGGER, CUSTOM_TIME_RANGE_BADGE); + uiActions.registerActionAsync(CPS_USAGE_OVERRIDES_BADGE, async () => { + const { CpsUsageOverridesBadge } = await import('../panel_component/panel_module'); + return new CpsUsageOverridesBadge(); + }); + uiActions.attachAction(PANEL_BADGE_TRIGGER, CPS_USAGE_OVERRIDES_BADGE); + uiActions.registerActionAsync(ACTION_INSPECT_PANEL, async () => { const { InspectPanelAction } = await import('../panel_component/panel_module'); return new InspectPanelAction(); diff --git a/src/platform/plugins/private/presentation_panel/public/panel_component/panel_module.ts b/src/platform/plugins/private/presentation_panel/public/panel_component/panel_module.ts index 2913695e7b56f..55a54a00fae95 100644 --- a/src/platform/plugins/private/presentation_panel/public/panel_component/panel_module.ts +++ b/src/platform/plugins/private/presentation_panel/public/panel_component/panel_module.ts @@ -11,6 +11,7 @@ export { PresentationPanelInternal } from './presentation_panel_internal'; export { PresentationPanelErrorInternal } from './presentation_panel_error_internal'; export { RemovePanelAction } from '../panel_actions/remove_panel_action/remove_panel_action'; export { CustomTimeRangeBadge } from '../panel_actions/customize_panel_action'; +export { CpsUsageOverridesBadge } from '../panel_actions/customize_panel_action'; export { CustomizePanelAction } from '../panel_actions/customize_panel_action'; export { EditPanelAction } from '../panel_actions/edit_panel_action/edit_panel_action'; export { ShowConfigPanelAction } from '../panel_actions/show_config_panel_action/show_config_panel_action'; diff --git a/src/platform/plugins/shared/discover/kibana.jsonc b/src/platform/plugins/shared/discover/kibana.jsonc index 572f5ec41c6e7..fcfec41dd46b8 100644 --- a/src/platform/plugins/shared/discover/kibana.jsonc +++ b/src/platform/plugins/shared/discover/kibana.jsonc @@ -51,7 +51,8 @@ "embeddableEnhanced", "apmSourcesAccess", "fileUpload", - "metricsExperience" + "metricsExperience", + "cps" ], "requiredBundles": [ "kibanaUtils", diff --git a/src/platform/plugins/shared/discover/moon.yml b/src/platform/plugins/shared/discover/moon.yml index f247055a94e66..3785f2063b4bf 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -127,6 +127,7 @@ dependsOn: - '@kbn/unified-metrics-grid' - '@kbn/shared-ux-link-redirect-app' - '@kbn/react-query' + - '@kbn/cps' tags: - plugin - prod diff --git a/src/platform/plugins/shared/discover/public/__mocks__/discover_state.mock.ts b/src/platform/plugins/shared/discover/public/__mocks__/discover_state.mock.ts index dd165e55fa057..5fceb3bec8c76 100644 --- a/src/platform/plugins/shared/discover/public/__mocks__/discover_state.mock.ts +++ b/src/platform/plugins/shared/discover/public/__mocks__/discover_state.mock.ts @@ -289,6 +289,8 @@ export function getDiscoverStateMock({ }), ...additionalPersistedTabs, ], + projectRouting: + finalSavedSearch.projectRouting === null ? undefined : finalSavedSearch.projectRouting, } : undefined; const mockUserId = 'mockUserId'; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/on_save_discover_session.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/on_save_discover_session.test.tsx index cb4665f4a97ea..2d76c7f63804f 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/on_save_discover_session.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/on_save_discover_session.test.tsx @@ -46,6 +46,7 @@ const getOnSaveProps = (props?: Partial): OnSaveProps => ({ newCopyOnSave: false, newDescription: 'description', newTimeRestore: false, + newProjectRoutingRestore: false, newTags: [], isTitleDuplicateConfirmed: false, onTitleDuplicate: jest.fn(), @@ -62,6 +63,8 @@ const setup = async ({ ...discoverSession, id: discoverSession.id ?? 'new-session', managed: false, + projectRouting: + discoverSession.projectRouting === null ? undefined : discoverSession.projectRouting, }); }, onSaveCb, @@ -133,6 +136,9 @@ describe('onSaveDiscoverSession', () => { onSave: expect.any(Function), onClose: expect.any(Function), managed: true, + initialCopyOnSave: undefined, + projectRoutingRestore: false, + showStoreProjectRoutingOnSave: false, }); }); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/on_save_discover_session.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/on_save_discover_session.tsx index dcd048bfa81e7..59044006f0f40 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/on_save_discover_session.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/on_save_discover_session.tsx @@ -71,6 +71,7 @@ export const onSaveDiscoverSession = async ({ newTitle, newCopyOnSave, newTimeRestore, + newProjectRoutingRestore, newDescription, newTags, isTitleDuplicateConfirmed, @@ -86,6 +87,7 @@ export const onSaveDiscoverSession = async ({ internalStateActions.saveDiscoverSession({ newTitle, newTimeRestore, + newProjectRoutingRestore, newCopyOnSave, newDescription, newTags, @@ -143,6 +145,8 @@ export const onSaveDiscoverSession = async ({ managed={persistedDiscoverSession?.managed ?? false} onSave={onSave} onClose={onClose ?? (() => {})} + showStoreProjectRoutingOnSave={Boolean(services.cps?.cpsManager)} + projectRoutingRestore={persistedDiscoverSession?.projectRouting !== undefined} /> ); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/project_routing_switch.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/project_routing_switch.test.tsx new file mode 100644 index 0000000000000..16c46ba5a1764 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/project_routing_switch.test.tsx @@ -0,0 +1,72 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ProjectRoutingSwitchProps } from './project_routing_switch'; +import { ProjectRoutingSwitch } from './project_routing_switch'; +import { I18nProvider } from '@kbn/i18n-react'; + +describe('ProjectRoutingSwitch', () => { + const renderComponent = (overrides?: Partial) => { + const defaultProps: ProjectRoutingSwitchProps = { + checked: false, + onChange: jest.fn(), + }; + return render( + + + + ); + }; + + it('should render the switch with correct label', () => { + renderComponent(); + + const label = screen.getByText('Include cross-project search scope customizations'); + expect(label).toBeInTheDocument(); + }); + + it('should render unchecked when checked prop is false', () => { + renderComponent(); + + const switchElement = screen.getByTestId('storeProjectRoutingWithSearch'); + expect(switchElement).not.toBeChecked(); + }); + + it('should render checked when checked prop is true', () => { + renderComponent({ checked: true }); + + const switchElement = screen.getByTestId('storeProjectRoutingWithSearch'); + expect(switchElement).toBeChecked(); + }); + + it('should call onChange with true when unchecked switch is clicked', async () => { + const onChange = jest.fn(); + renderComponent({ onChange }); + + const switchElement = screen.getByTestId('storeProjectRoutingWithSearch'); + await userEvent.click(switchElement); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it('should call onChange with false when checked switch is clicked', async () => { + const onChange = jest.fn(); + renderComponent({ checked: true, onChange }); + + const switchElement = screen.getByTestId('storeProjectRoutingWithSearch'); + await userEvent.click(switchElement); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/project_routing_switch.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/project_routing_switch.tsx new file mode 100644 index 0000000000000..4ea4e066f9c34 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/project_routing_switch.tsx @@ -0,0 +1,38 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +export interface ProjectRoutingSwitchProps { + checked: boolean; + onChange: (checked: boolean) => void; +} + +export const ProjectRoutingSwitch: React.FC = ({ + checked, + onChange, +}) => { + return ( + + onChange(event.target.checked)} + label={ + + } + /> + + ); +}; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/save_modal.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/save_modal.tsx index b85a7bf81e1f8..9a573947c7c0b 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/save_modal.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/save_modal.tsx @@ -14,19 +14,26 @@ import { FormattedMessage } from '@kbn/i18n-react'; import type { OnSaveProps, SaveResult } from '@kbn/saved-objects-plugin/public'; import { SavedObjectSaveModal } from '@kbn/saved-objects-plugin/public'; import type { DiscoverServices } from '../../../../../build_services'; +import { ProjectRoutingSwitch } from './project_routing_switch'; export type DiscoverSessionSaveModalOnSaveCallback = ( - props: OnSaveProps & { newTimeRestore: boolean; newTags: string[] } + props: OnSaveProps & { + newTimeRestore: boolean; + newProjectRoutingRestore: boolean; + newTags: string[]; + } ) => Promise; export interface DiscoverSessionSaveModalProps { isTimeBased: boolean; + showStoreProjectRoutingOnSave: boolean; services: DiscoverServices; title: string; showCopyOnSave: boolean; initialCopyOnSave?: boolean; description?: string; timeRestore?: boolean; + projectRoutingRestore?: boolean; tags: string[]; onSave: DiscoverSessionSaveModalOnSaveCallback; onClose: () => void; @@ -35,6 +42,7 @@ export interface DiscoverSessionSaveModalProps { export const DiscoverSessionSaveModal: React.FC = ({ isTimeBased, + showStoreProjectRoutingOnSave, services: { savedObjectsTagging, discoverFeatureFlags }, title, description, @@ -42,11 +50,15 @@ export const DiscoverSessionSaveModal: React.FC = showCopyOnSave, initialCopyOnSave, timeRestore: savedTimeRestore, + projectRoutingRestore: savedProjectRoutingRestore, onSave, onClose, managed, }) => { const [timeRestore, setTimeRestore] = useState(Boolean(isTimeBased && savedTimeRestore)); + const [projectRoutingRestore, setProjectRoutingRestore] = useState( + Boolean(savedProjectRoutingRestore) + ); const [currentTags, setCurrentTags] = useState(tags); const tabsEnabled = discoverFeatureFlags.getTabsEnabled(); @@ -54,6 +66,7 @@ export const DiscoverSessionSaveModal: React.FC = await onSave({ ...params, newTimeRestore: timeRestore, + newProjectRoutingRestore: projectRoutingRestore, newTags: currentTags, }); }; @@ -68,14 +81,7 @@ export const DiscoverSessionSaveModal: React.FC = ) : null; const timeSwitch = isTimeBased ? ( - - } - > + = label={ } /> @@ -93,6 +99,9 @@ export const DiscoverSessionSaveModal: React.FC = const options = ( <> {tagSelector} + {showStoreProjectRoutingOnSave && ( + + )} {timeSwitch} ); diff --git a/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_all.ts b/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_all.ts index e6e2a85e98af1..33671fbe633b6 100644 --- a/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_all.ts +++ b/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_all.ts @@ -68,6 +68,7 @@ export function fetchAll( dataSubjects, reset = false, initialFetchStatus, + internalState, services, scopedProfilesManager, scopedEbtManager, @@ -97,6 +98,7 @@ export function fetchAll( services, sort: sort as SortOrder[], inputTimeRange: currentTab.dataRequestParams.timeRangeAbsolute, + projectRouting: internalState.getState().projectRouting, }); } @@ -120,6 +122,7 @@ export function fetchAll( timeRange: currentTab.dataRequestParams.timeRangeAbsolute, esqlVariables: currentTab.esqlVariables, searchSessionId: params.searchSessionId, + projectRouting: internalState.getState().projectRouting, }) : fetchDocuments(searchSource, params); const fetchType = isEsqlQuery ? 'fetchTextBased' : 'fetchDocuments'; @@ -228,7 +231,7 @@ export function fetchAll( } export async function fetchMoreDocuments(params: CommonFetchParams): Promise { - const { dataSubjects, services, savedSearch, getCurrentTab } = params; + const { dataSubjects, services, savedSearch, internalState, getCurrentTab } = params; try { const searchSource = savedSearch.searchSource.createChild(); @@ -258,6 +261,7 @@ export async function fetchMoreDocuments(params: CommonFetchParams): Promise { services: mockServices, }), ], + projectRouting: + savedSearchWithDefaults.projectRouting === null + ? undefined + : savedSearchWithDefaults.projectRouting, }); await state.internalState.dispatch( internalStateActions.initializeTabs({ discoverSessionId: savedSearchWithDefaults.id }) @@ -963,6 +967,10 @@ describe('Discover state', () => { services: mockServices, }), ], + projectRouting: + savedSearchWithDefaults.projectRouting === null + ? undefined + : savedSearchWithDefaults.projectRouting, }); await state.internalState.dispatch( internalStateActions.initializeTabs({ discoverSessionId: savedSearchWithDefaults.id }) diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts index f88c6f8995d4f..83edbd6db5e25 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts @@ -568,6 +568,17 @@ export function getDiscoverStateContainer({ } ); + // Subscribe to CPS projectRouting changes (global subscription affects all tabs) + const cpsProjectRoutingSubscription = services.cps?.cpsManager + ?.getProjectRouting$() + .subscribe((cpsProjectRouting) => { + const currentProjectRouting = internalState.getState().projectRouting; + + if (cpsProjectRouting !== currentProjectRouting) { + internalState.dispatch(internalStateActions.setProjectRouting(cpsProjectRouting)); + } + }); + const { start: startSyncingGlobalStateWithUrl, stop: stopSyncingGlobalStateWithUrl } = syncState({ storageKey: GLOBAL_STATE_URL_KEY, @@ -588,6 +599,7 @@ export function getDiscoverStateContainer({ stopSyncingQueryGlobalStateWithStateContainer(); stopSyncingAppStateWithUrl(); stopSyncingGlobalStateWithUrl(); + cpsProjectRoutingSubscription?.unsubscribe(); }; }; @@ -634,6 +646,17 @@ export function getDiscoverStateContainer({ }) ); + // Subscribe to session-level projectRouting changes and trigger data fetch + let previousProjectRouting = internalState.getState().projectRouting; + const projectRoutingUnsubscribe = internalState.subscribe(() => { + const currentProjectRouting = internalState.getState().projectRouting; + if (currentProjectRouting !== previousProjectRouting) { + previousProjectRouting = currentProjectRouting; + addLog('[getDiscoverStateContainer] projectRouting changes triggers data fetching'); + fetchData(); + } + }); + const savedSearchChangesSubscription = savedSearchContainer .getCurrent$() .subscribe(syncLocallyPersistedTabState); @@ -675,6 +698,7 @@ export function getDiscoverStateContainer({ unsubscribeData(); appStateSubscription.unsubscribe(); unsubscribeUrlState(); + projectRoutingUnsubscribe(); unsubscribeSavedSearchUrlTracking(); filterUnsubscribe.unsubscribe(); timefilerUnsubscribe.unsubscribe(); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/reset_discover_session.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/reset_discover_session.ts index 1721f73382200..981a7e2554b29 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/reset_discover_session.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/reset_discover_session.ts @@ -94,6 +94,20 @@ export const resetDiscoverSession = createInternalStateAsyncThunk( const selectedTab = allTabs.find((tab) => tab.id === selectedTabId) ?? allTabs[0]; + if (services.cps?.cpsManager) { + const restoredProjectRouting = discoverSession.projectRouting; + // If a saved value exists, restore it + if (restoredProjectRouting) { + dispatch(internalStateSlice.actions.setProjectRouting(restoredProjectRouting)); + services.cps.cpsManager.setProjectRouting(restoredProjectRouting); + } else if (!updatedDiscoverSession) { + // Only reset to default when switching between saved objects (not after a save operation) + // This preserves the picker value after "Save As" when projectRouting is not stored + const defaultRouting = services.cps.cpsManager.getDefaultProjectRouting(); + dispatch(internalStateSlice.actions.setProjectRouting(defaultRouting)); + services.cps.cpsManager.setProjectRouting(defaultRouting); + } + } await dispatch( updateTabs({ items: allTabs, selectedItem: selectedTab, updatedDiscoverSession }) ); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.test.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.test.ts index 6be13fc5d7ef6..8b216ceb84c25 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.test.ts @@ -32,6 +32,7 @@ const getSaveDiscoverSessionParams = ( newTitle: 'new title', newCopyOnSave: false, newTimeRestore: false, + newProjectRoutingRestore: false, newDescription: 'new description', newTags: [], isTitleDuplicateConfirmed: false, @@ -52,6 +53,8 @@ const setup = ({ ...discoverSession, id: discoverSession.id ?? 'new-session', managed: false, + projectRouting: + discoverSession.projectRouting === null ? undefined : discoverSession.projectRouting, }) ); const dataViewCreateSpy = jest.spyOn(services.dataViews, 'create'); @@ -106,6 +109,7 @@ describe('saveDiscoverSession', () => { description: 'new description', tabs: discoverSession?.tabs ?? [], tags: ['tag1', 'tag2'], + projectRouting: undefined, }; expect(saveDiscoverSessionSpy).toHaveBeenCalledWith(updatedDiscoverSession, { @@ -117,6 +121,7 @@ describe('saveDiscoverSession', () => { expect(state.internalState.getState().persistedDiscoverSession).toEqual({ ...updatedDiscoverSession, managed: false, + projectRouting: undefined, }); }); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.ts index 53ed7af28ed58..e8d37cfee04de 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.ts @@ -33,6 +33,7 @@ type AdHocDataViewAction = 'copy' | 'replace'; export interface SaveDiscoverSessionThunkParams { newTitle: string; newTimeRestore: boolean; + newProjectRoutingRestore: boolean; newCopyOnSave: boolean; newDescription: string; newTags: string[]; @@ -47,6 +48,7 @@ export const saveDiscoverSession = createInternalStateAsyncThunk( newTitle, newCopyOnSave, newTimeRestore, + newProjectRoutingRestore, newDescription, newTags, isTitleDuplicateConfirmed, @@ -213,6 +215,14 @@ export const saveDiscoverSession = createInternalStateAsyncThunk( tags: services.savedObjectsTagging ? newTags : state.persistedDiscoverSession?.tags, }; + // Handle projectRouting: only include if toggle is ON, or if we need to explicitly clear it + if (newProjectRoutingRestore) { + saveParams.projectRouting = state.projectRouting; + } else if (state.persistedDiscoverSession?.projectRouting !== undefined) { + // Explicitly clear if it existed before + saveParams.projectRouting = null; + } + const saveOptions: SaveDiscoverSessionOptions = { onTitleDuplicate, copyOnSave: newCopyOnSave, diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.ts index 956dd6e63184c..664e0d35a6b8a 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.ts @@ -51,6 +51,7 @@ const initialState: DiscoverInternalState = { userId: undefined, spaceId: undefined, persistedDiscoverSession: undefined, + projectRouting: undefined, hasUnsavedChanges: false, defaultProfileAdHocDataViewIds: [], savedDataViews: [], @@ -151,6 +152,21 @@ export const internalStateSlice = createSlice({ state.tabsBarVisibility = action.payload; }, + setProjectRouting: (state, action: PayloadAction) => { + if (state.projectRouting === action.payload) { + return; + } + state.projectRouting = action.payload; + + // Mark all non-active tabs to refetch on selection + const currentTabId = state.tabs.unsafeCurrentId; + state.tabs.allIds.forEach((tabId) => { + if (tabId !== currentTabId && state.tabs.byId[tabId]) { + state.tabs.byId[tabId].forceFetchOnSelect = true; + } + }); + }, + setExpandedDoc: ( state, action: PayloadAction<{ @@ -398,6 +414,21 @@ const createMiddleware = (options: InternalStateDependencies) => { }, }); + startListening({ + actionCreator: initializeTabs.fulfilled, + effect: (action, listenerApi) => { + const { services } = listenerApi.extra; + const persistedSession = action.payload.persistedDiscoverSession; + + // Initialize CPS manager with session-level projectRouting after state is updated + if (services.cps?.cpsManager) { + const projectRouting = + persistedSession?.projectRouting ?? services.cps.cpsManager.getDefaultProjectRouting(); + services.cps.cpsManager.setProjectRouting(projectRouting); + } + }, + }); + return listenerMiddleware.middleware; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/selectors/index.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/selectors/index.ts index dd22b5fb36e73..67f1e8dc770bf 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/selectors/index.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/selectors/index.ts @@ -7,5 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { selectAllTabs, selectRecentlyClosedTabs, selectTab, selectIsTabsBarHidden } from './tabs'; +export { + selectAllTabs, + selectRecentlyClosedTabs, + selectTab, + selectIsTabsBarHidden, + selectProjectRouting, +} from './tabs'; export { type HasUnsavedChangesResult, selectHasUnsavedChanges } from './unsaved_changes'; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/selectors/tabs.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/selectors/tabs.ts index 6a437b9e81bac..eb1dfc3733373 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/selectors/tabs.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/selectors/tabs.ts @@ -34,3 +34,5 @@ export const selectIsTabsBarHidden = createSelector( (state: DiscoverInternalState) => state.tabsBarVisibility, (tabsBarVisibility) => tabsBarVisibility === TabsBarVisibility.hidden ); + +export const selectProjectRouting = (state: DiscoverInternalState) => state.projectRouting; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/selectors/unsaved_changes.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/selectors/unsaved_changes.ts index c4fad83ce7727..73b141a0d7bbf 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/selectors/unsaved_changes.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/selectors/unsaved_changes.ts @@ -61,6 +61,18 @@ export const selectHasUnsavedChanges = ( }); } + // If the persisted session has projectRouting compare it with current projectRouting + const persistedProjectRouting = persistedDiscoverSession.projectRouting; + const projectRoutingChanged = + persistedProjectRouting !== undefined && persistedProjectRouting !== state.projectRouting; + + if (projectRoutingChanged) { + addLog('[DiscoverSession] difference between initial and changed version: projectRouting', { + before: persistedProjectRouting, + after: state.projectRouting, + }); + } + const unsavedTabIds: string[] = []; for (const tabId of currentTabsIds) { @@ -106,7 +118,7 @@ export const selectHasUnsavedChanges = ( } } - const hasUnsavedChanges = tabIdsChanged || unsavedTabIds.length > 0; + const hasUnsavedChanges = tabIdsChanged || projectRoutingChanged || unsavedTabIds.length > 0; if (!hasUnsavedChanges) { addLog('[DiscoverSession] no difference between initial and changed version'); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/types.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/types.ts index 9245268f509e5..ba816e910db53 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/types.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/types.ts @@ -11,7 +11,7 @@ import type { RefreshInterval, SerializedSearchSourceFields } from '@kbn/data-pl import type { DataViewListItem } from '@kbn/data-views-plugin/public'; import type { ControlPanelsState } from '@kbn/controls-plugin/common'; import type { DataTableRecord } from '@kbn/discover-utils'; -import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; +import type { AggregateQuery, Filter, ProjectRouting, Query, TimeRange } from '@kbn/es-query'; import type { ESQLControlState, ESQLControlVariable } from '@kbn/esql-types'; import type { DataGridDensity, UnifiedDataTableRestorableState } from '@kbn/unified-data-table'; import type { UnifiedMetricsGridRestorableState } from '@kbn/unified-metrics-grid'; @@ -165,6 +165,7 @@ export interface DiscoverInternalState { userId: string | undefined; spaceId: string | undefined; persistedDiscoverSession: DiscoverSession | undefined; + projectRouting?: ProjectRouting; hasUnsavedChanges: boolean; savedDataViews: DataViewListItem[]; defaultProfileAdHocDataViewIds: string[]; diff --git a/src/platform/plugins/shared/discover/public/build_services.ts b/src/platform/plugins/shared/discover/public/build_services.ts index 78a7b50c7bf45..e1345598e1f44 100644 --- a/src/platform/plugins/shared/discover/public/build_services.ts +++ b/src/platform/plugins/shared/discover/public/build_services.ts @@ -64,6 +64,7 @@ import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/publ import type { LogsDataAccessPluginStart } from '@kbn/logs-data-access-plugin/public'; import type { DiscoverSharedPublicStart } from '@kbn/discover-shared-plugin/public'; import type { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public'; +import type { CPSPluginStart } from '@kbn/cps/public'; import type { DiscoverStartPlugins } from './types'; import type { DiscoverContextAppLocator } from './application/context/services/locator'; import type { DiscoverSingleDocLocator } from './application/doc/locator'; @@ -150,6 +151,7 @@ export interface DiscoverServices { fieldsMetadata?: FieldsMetadataPublicStart; logsDataAccess?: LogsDataAccessPluginStart; embeddableEnhanced?: EmbeddableEnhancedPluginStart; + cps?: CPSPluginStart; } export const buildServices = ({ @@ -247,5 +249,6 @@ export const buildServices = ({ fieldsMetadata: plugins.fieldsMetadata, logsDataAccess: plugins.logsDataAccess, embeddableEnhanced: plugins.embeddableEnhanced, + cps: plugins.cps, }; }; diff --git a/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx b/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx index ca18454e48fca..77276738dbf42 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx +++ b/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx @@ -29,7 +29,9 @@ import { type AggregateQuery, type Filter, type Query, + type ProjectRouting, } from '@kbn/es-query'; +import type { PublishesProjectRouting } from '@kbn/presentation-publishing'; import type { DiscoverServices } from '../build_services'; import { EDITABLE_SAVED_SEARCH_KEYS } from '../../common/embeddable/constants'; import { getSearchEmbeddableDefaults } from './get_search_embeddable_defaults'; @@ -88,6 +90,7 @@ export const initializeSearchEmbeddableApi = async ( ): Promise<{ api: PublishesWritableSavedSearch & PublishesWritableDataViews & + PublishesProjectRouting & Partial; stateManager: SearchEmbeddableStateManager; anyStateChange$: Observable; @@ -115,6 +118,9 @@ export const initializeSearchEmbeddableApi = async ( const density$ = new BehaviorSubject(initialState.density); const sort$ = new BehaviorSubject(initialState.sort); const savedSearchViewMode$ = new BehaviorSubject(initialState.viewMode); + const projectRouting$ = new BehaviorSubject( + initialState.projectRouting ?? undefined + ); /** * This is the state that comes from the search source that needs individual publishing subjects for the API @@ -217,6 +223,7 @@ export const initializeSearchEmbeddableApi = async ( setQuery, canEditUnifiedSearch, setColumns, + projectRouting$, }, stateManager, anyStateChange$: onAnyStateChange.pipe(map(() => undefined)), @@ -243,6 +250,7 @@ export const initializeSearchEmbeddableApi = async ( headerRowHeight$.next(lastSaved?.headerRowHeight); savedSearchViewMode$.next(lastSaved?.viewMode); density$.next(lastSaved?.density); + projectRouting$.next(lastSaved?.projectRouting ?? undefined); }, }; }; diff --git a/src/platform/plugins/shared/discover/public/embeddable/types.ts b/src/platform/plugins/shared/discover/public/embeddable/types.ts index c4a534d43f352..073428ae60f4a 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/types.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/types.ts @@ -10,6 +10,7 @@ import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; import type { HasInspectorAdapters } from '@kbn/inspector-plugin/public'; +import type { ProjectRouting } from '@kbn/es-query'; import type { EmbeddableApiContext, HasEditCapabilities, @@ -78,6 +79,7 @@ export type SearchEmbeddableRuntimeState = SearchEmbeddableSerializedAttributes savedObjectId?: string; savedObjectDescription?: string; nonPersistedDisplayOptions?: NonPersistedDisplayOptions; + projectRouting?: ProjectRouting; }; export type SearchEmbeddableApi = DefaultEmbeddableApi & diff --git a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts index 94dd3eb8aa7d2..57d462609d748 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts @@ -62,35 +62,68 @@ describe('Serialization utils', () => { ], }; + const serializedByValueState: SerializedPanelState = { + rawState: { + attributes: mockedSavedSearchAttributes, + title: 'test panel title', + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + id: dataViewMock.id ?? 'test-id', + type: 'index-pattern', + }, + ], + }; + describe('deserialize state', () => { test('by value', async () => { - const serializedState: SerializedPanelState = { - rawState: { - attributes: mockedSavedSearchAttributes, - title: 'test panel title', - }, - references: [ - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - id: dataViewMock.id ?? 'test-id', - type: 'index-pattern', - }, - ], - }; - const deserializedState = await deserializeState({ - serializedState, + serializedState: serializedByValueState, discoverServices: discoverServiceMock, }); expect(discoverServiceMock.savedSearch.byValueToSavedSearch).toBeCalledWith( - serializedState.rawState, + serializedByValueState.rawState, true // should be serializable ); expect(Object.keys(deserializedState)).toContain('serializedSearchSource'); expect(deserializedState.title).toEqual('test panel title'); }); + test('by value with null projectRouting should transform to undefined', async () => { + (serializedByValueState.rawState as SearchEmbeddableByValueState)!.attributes.projectRouting = + null; + + const deserializedState = await deserializeState({ + serializedState: serializedByValueState, + discoverServices: discoverServiceMock, + }); + + expect(deserializedState.projectRouting).toBeUndefined(); + expect('projectRouting' in deserializedState).toBe(false); + }); + + test('by value with projectRouting should preserve it', async () => { + // Mock byValueToSavedSearch to return projectRouting + discoverServiceMock.savedSearch.byValueToSavedSearch = jest.fn().mockResolvedValue({ + projectRouting: 'project1', + serializedSearchSource: {}, + title: 'test', + tabs: mockedSavedSearchAttributes.tabs, + }); + + (serializedByValueState.rawState as SearchEmbeddableByValueState)!.attributes.projectRouting = + 'test1'; + + const deserializedState = await deserializeState({ + serializedState: serializedByValueState, + discoverServices: discoverServiceMock, + }); + + expect(deserializedState.projectRouting).toBe('project1'); + }); + test('by reference', async () => { discoverServiceMock.savedSearch.get = jest.fn().mockReturnValue({ savedObjectId: 'savedSearch', @@ -120,6 +153,65 @@ describe('Serialization utils', () => { expect(deserializedState.title).toEqual('test panel title'); expect(deserializedState.sort).toEqual([['order_date', 'asc']]); }); + + test('by reference with null projectRouting should transform to undefined', async () => { + const baseSearch = await discoverServiceMock.savedSearch.byValueToSavedSearch( + { + attributes: mockedSavedSearchAttributes, + }, + true + ); + + discoverServiceMock.savedSearch.get = jest.fn().mockResolvedValue({ + ...baseSearch, + savedObjectId: 'savedSearch', + projectRouting: null, + }); + + const serializedState: SerializedPanelState = { + rawState: { + savedObjectId: 'savedSearch', + }, + references: [], + }; + + const deserializedState = await deserializeState({ + serializedState, + discoverServices: discoverServiceMock, + }); + + expect(deserializedState.projectRouting).toBeUndefined(); + expect('projectRouting' in deserializedState).toBe(false); + }); + + test('by reference with projectRouting should preserve it', async () => { + const baseSearch = await discoverServiceMock.savedSearch.byValueToSavedSearch( + { + attributes: mockedSavedSearchAttributes, + }, + true + ); + + discoverServiceMock.savedSearch.get = jest.fn().mockResolvedValue({ + ...baseSearch, + savedObjectId: 'savedSearch', + projectRouting: 'ALL', + }); + + const serializedState: SerializedPanelState = { + rawState: { + savedObjectId: 'savedSearch', + }, + references: [], + }; + + const deserializedState = await deserializeState({ + serializedState, + discoverServices: discoverServiceMock, + }); + + expect(deserializedState.projectRouting).toBe('ALL'); + }); }); describe('serialize state', () => { @@ -137,6 +229,7 @@ describe('Serialization utils', () => { uuid, initialState: { ...mockedSavedSearchAttributes, + projectRouting: undefined, serializedSearchSource: {} as SerializedSearchSourceFields, }, savedSearch, diff --git a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts index 507dd9dc4711c..67953b6e59de1 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts @@ -44,11 +44,15 @@ export const deserializeState = async ({ const { get } = discoverServices.savedSearch; const so = await get(savedObjectId, true); + // what is this?? const rawSavedObjectAttributes = pick(so, EDITABLE_SAVED_SEARCH_KEYS); const savedObjectOverride = pick(serializedState.rawState, EDITABLE_SAVED_SEARCH_KEYS); + // Filter out null projectRouting for type safety (runtime already filtered by fromSavedSearchAttributes) + const { projectRouting, ...soWithoutTimeRangeAndProjectRouting } = omit(so, 'timeRange'); return { - // ignore the time range from the saved object - only global time range + panel time range matter - ...omit(so, 'timeRange'), + ...soWithoutTimeRangeAndProjectRouting, + // Only include projectRouting if it's not null (SearchEmbeddableRuntimeState doesn't allow null) + ...(projectRouting !== null && projectRouting !== undefined && { projectRouting }), savedObjectId, savedObjectTitle: so.title, savedObjectDescription: so.description, @@ -67,8 +71,12 @@ export const deserializeState = async ({ serializedState.rawState as SearchEmbeddableByValueState, true ); + // Filter out null projectRouting for type safety (runtime already filtered by fromSavedSearchAttributes) + const { projectRouting, ...savedSearchWithoutProjectRouting } = savedSearch; return { - ...savedSearch, + ...savedSearchWithoutProjectRouting, + // Only include projectRouting if it's not null (SearchEmbeddableRuntimeState doesn't allow null) + ...(projectRouting !== null && projectRouting !== undefined && { projectRouting }), ...panelState, nonPersistedDisplayOptions: serializedState.rawState.nonPersistedDisplayOptions, }; diff --git a/src/platform/plugins/shared/discover/public/types.ts b/src/platform/plugins/shared/discover/public/types.ts index 0046b414544a7..46fba0ff0ea0a 100644 --- a/src/platform/plugins/shared/discover/public/types.ts +++ b/src/platform/plugins/shared/discover/public/types.ts @@ -48,6 +48,7 @@ import type { ApmSourceAccessPluginStart } from '@kbn/apm-sources-access-plugin/ import type { Setup as InspectorPublicPluginSetup } from '@kbn/inspector-plugin/public/plugin'; import type { FileUploadPluginStart } from '@kbn/file-upload-plugin/public'; import type { MetricsExperiencePluginStart } from '@kbn/metrics-experience-plugin/public'; +import type { CPSPluginStart } from '@kbn/cps/public'; import type { DiscoverAppLocator } from '../common'; import type { DiscoverContainerProps } from './components/discover_container'; @@ -183,4 +184,5 @@ export interface DiscoverStartPlugins { apmSourcesAccess?: ApmSourceAccessPluginStart; fileUpload?: FileUploadPluginStart; metricsExperience?: MetricsExperiencePluginStart; + cps?: CPSPluginStart; } diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index c10b010840395..53c69c2e37dba 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -119,7 +119,8 @@ "@kbn/metrics-experience-plugin", "@kbn/unified-metrics-grid", "@kbn/shared-ux-link-redirect-app", - "@kbn/react-query" + "@kbn/react-query", + "@kbn/cps" ], "exclude": ["target/**/*"] } diff --git a/src/platform/plugins/shared/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx b/src/platform/plugins/shared/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx index f079d077ecf8b..08f4fc26ffbc9 100644 --- a/src/platform/plugins/shared/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx +++ b/src/platform/plugins/shared/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx @@ -9,7 +9,7 @@ import React, { Fragment, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { EuiFormRow, EuiSwitch, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { OnSaveProps, SaveModalState, SaveResult } from '.'; @@ -30,11 +30,18 @@ export interface OriginSaveModalProps { objectType: string; onClose: () => void; options?: React.ReactNode | ((state: SaveModalState) => React.ReactNode); - onSave: (props: OnSaveProps & { returnToOrigin: boolean }) => Promise; + onSave: ( + props: OnSaveProps & { returnToOrigin: boolean; newProjectRoutingRestore?: boolean } + ) => Promise; + projectRoutingRestore?: boolean; + showStoreProjectRoutingOnSave?: boolean; } export function SavedObjectSaveModalOrigin(props: OriginSaveModalProps) { const [returnToOriginMode, setReturnToOriginMode] = useState(Boolean(props.originatingApp)); + const [persistSelectedProjectRouting, setPersistSelectedProjectRouting] = useState( + props.projectRoutingRestore ?? false + ); const { documentInfo } = props; const returnLabel = i18n.translate('savedObjects.saveModalOrigin.returnToOriginLabel', { @@ -48,8 +55,34 @@ export function SavedObjectSaveModalOrigin(props: OriginSaveModalProps) { const sourceOptions = typeof props.options === 'function' ? props.options(state) : props.options; + const projectRoutingOptions = props.showStoreProjectRoutingOnSave ? ( + + + + setPersistSelectedProjectRouting(event.target.checked)} + label={ + + } + /> + + + + ) : null; + if (!props.originatingApp) { - return sourceOptions; + return ( + + {sourceOptions} + {projectRoutingOptions} + + ); } const origin = props.getAppNameFromId ? props.getAppNameFromId(props.originatingApp) || props.originatingApp @@ -63,6 +96,7 @@ export function SavedObjectSaveModalOrigin(props: OriginSaveModalProps) { return ( {sourceOptions} + {projectRoutingOptions} + {sourceOptions} + {projectRoutingOptions} + + ); } }; const onModalSave = async (onSaveProps: OnSaveProps): Promise => { - return props.onSave({ ...onSaveProps, returnToOrigin: returnToOriginMode }); + return props.onSave({ + ...onSaveProps, + returnToOrigin: returnToOriginMode, + newProjectRoutingRestore: props.showStoreProjectRoutingOnSave + ? persistSelectedProjectRouting + : undefined, + }); }; const confirmButtonLabel = returnToOriginMode diff --git a/src/platform/plugins/shared/saved_search/common/saved_searches_utils.ts b/src/platform/plugins/shared/saved_search/common/saved_searches_utils.ts index 73b4370d78fac..02a973ad7eaee 100644 --- a/src/platform/plugins/shared/saved_search/common/saved_searches_utils.ts +++ b/src/platform/plugins/shared/saved_search/common/saved_searches_utils.ts @@ -16,7 +16,7 @@ export const fromSavedSearchAttributes = < ReturnType = Serialized extends true ? SerializableSavedSearch : SavedSearch >( id: string | undefined, - { title, description, tabs }: SavedSearchAttributes, + { title, description, tabs, projectRouting }: SavedSearchAttributes, tags: string[] | undefined, searchSource: SavedSearch['searchSource'] | SerializedSearchSourceFields, managed: boolean, @@ -50,6 +50,8 @@ export const fromSavedSearchAttributes = < visContext: attributes.visContext, controlGroupJson: attributes.controlGroupJson, density: attributes.density, + // Only include projectRouting if it's not null/undefined (saved state allows null, runtime doesn't) + ...(projectRouting !== null && projectRouting !== undefined && { projectRouting }), tabs, managed, } as ReturnType; diff --git a/src/platform/plugins/shared/saved_search/common/service/get_discover_session.ts b/src/platform/plugins/shared/saved_search/common/service/get_discover_session.ts index b758784dfc784..e5a92acbc7c2c 100644 --- a/src/platform/plugins/shared/saved_search/common/service/get_discover_session.ts +++ b/src/platform/plugins/shared/saved_search/common/service/get_discover_session.ts @@ -50,6 +50,9 @@ export const getDiscoverSession = async ( visContext: tab.attributes.visContext, controlGroupJson: tab.attributes.controlGroupJson, })), + // Session-level projectRouting - normalize null to undefined for runtime usage + projectRouting: + so.item.attributes.projectRouting === null ? undefined : so.item.attributes.projectRouting, managed: Boolean(so.item.managed), tags: deps.savedObjectsTagging ? deps.savedObjectsTagging.ui.getTagIdsFromReferences(so.item.references) diff --git a/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.ts b/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.ts index 174a450ad8e83..eb09e7db1a080 100644 --- a/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.ts +++ b/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.ts @@ -52,6 +52,7 @@ export const toSavedSearchAttributes = ( timeRestore: savedSearch.timeRestore ?? false, timeRange: savedSearch.timeRange ? pick(savedSearch.timeRange, ['from', 'to']) : undefined, refreshInterval: savedSearch.refreshInterval, + projectRouting: savedSearch.projectRouting ?? undefined, rowsPerPage: savedSearch.rowsPerPage, sampleSize: savedSearch.sampleSize, density: savedSearch.density, diff --git a/src/platform/plugins/shared/saved_search/common/types.ts b/src/platform/plugins/shared/saved_search/common/types.ts index e87cab88684b7..67161e448f764 100644 --- a/src/platform/plugins/shared/saved_search/common/types.ts +++ b/src/platform/plugins/shared/saved_search/common/types.ts @@ -14,6 +14,7 @@ import type { SerializedSearchSourceFields, TimeRange, } from '@kbn/data-plugin/common'; +import type { ProjectRouting } from '@kbn/es-query'; import type { SavedObjectReference } from '@kbn/core-saved-objects-server'; import type { SavedObjectsResolveResponse } from '@kbn/core/server'; import type { SerializableRecord } from '@kbn/utility-types'; @@ -65,7 +66,8 @@ export interface SavedSearchAttributes { timeRestore?: boolean; timeRange?: Pick; refreshInterval?: RefreshInterval; - + // projectRouting can be null to explicitly clear the value in storage + projectRouting?: ProjectRouting | null; rowsPerPage?: number; sampleSize?: number; breakdownField?: string; @@ -87,7 +89,6 @@ export type SavedSearch = Partial & { searchSource: ISearchSource; id?: string; tags?: string[] | undefined; - // Whether or not this saved search is managed by the system managed: boolean; references?: SavedObjectReference[]; @@ -134,6 +135,7 @@ export interface DiscoverSession { title: string; description: string; tabs: DiscoverSessionTab[]; + projectRouting?: ProjectRouting; managed: boolean; tags?: string[] | undefined; references?: SavedObjectReference[]; diff --git a/src/platform/plugins/shared/saved_search/public/service/save_discover_session.ts b/src/platform/plugins/shared/saved_search/public/service/save_discover_session.ts index 7ee9171be9ce9..847f3bb30735a 100644 --- a/src/platform/plugins/shared/saved_search/public/service/save_discover_session.ts +++ b/src/platform/plugins/shared/saved_search/public/service/save_discover_session.ts @@ -23,7 +23,10 @@ export type SaveDiscoverSessionParams = Pick< DiscoverSession, 'title' | 'description' | 'tabs' | 'tags' > & - Partial>; + Partial> & { + // projectRouting can be undefined (not provided), a value, or null (to explicitly clear) + projectRouting?: DiscoverSession['projectRouting'] | null; + }; export interface SaveDiscoverSessionOptions { onTitleDuplicate?: () => void; @@ -135,6 +138,11 @@ export const saveDiscoverSession = async ( density: tabs[0].attributes.density as DataGridDensity, }; + // Only include projectRouting if explicitly provided (even if null to clear) + if ('projectRouting' in discoverSession) { + attributes.projectRouting = discoverSession.projectRouting ?? null; + } + const references = savedObjectsTagging ? savedObjectsTagging.ui.updateTagsReferences(tabReferences, discoverSession.tags ?? []) : tabReferences; @@ -146,5 +154,12 @@ export const saveDiscoverSession = async ( contentManagement ); - return { ...discoverSession, id, references, managed: false }; + return { + ...discoverSession, + projectRouting: + discoverSession.projectRouting === null ? undefined : discoverSession.projectRouting, + id, + references, + managed: false, + }; }; diff --git a/src/platform/plugins/shared/saved_search/server/content_management/saved_search_storage.ts b/src/platform/plugins/shared/saved_search/server/content_management/saved_search_storage.ts index 894bfe8f9c158..036e0e6e79b1b 100644 --- a/src/platform/plugins/shared/saved_search/server/content_management/saved_search_storage.ts +++ b/src/platform/plugins/shared/saved_search/server/content_management/saved_search_storage.ts @@ -56,6 +56,7 @@ export class SavedSearchStorage extends SOContentStorage { 'density', 'visContext', 'tabs', + 'projectRouting', ], logger, throwOnResultValidationError, diff --git a/src/platform/plugins/shared/saved_search/server/content_management/schema/v1/cm_services.ts b/src/platform/plugins/shared/saved_search/server/content_management/schema/v1/cm_services.ts index 9e5b05e47e2ac..dab20ef59f1e7 100644 --- a/src/platform/plugins/shared/saved_search/server/content_management/schema/v1/cm_services.ts +++ b/src/platform/plugins/shared/saved_search/server/content_management/schema/v1/cm_services.ts @@ -16,9 +16,9 @@ import { updateOptionsSchema, createResultSchema, } from '@kbn/content-management-utils'; -import { SCHEMA_SEARCH_MODEL_VERSION_8 } from '../../../saved_objects/schema'; +import { SCHEMA_SEARCH_MODEL_VERSION_10 } from '../../../saved_objects/schema'; -const savedSearchSavedObjectSchema = savedObjectSchema(SCHEMA_SEARCH_MODEL_VERSION_8); +const savedSearchSavedObjectSchema = savedObjectSchema(SCHEMA_SEARCH_MODEL_VERSION_10); const savedSearchCreateOptionsSchema = schema.maybe( schema.object({ @@ -56,7 +56,7 @@ export const serviceDefinition: ServicesDefinition = { schema: savedSearchCreateOptionsSchema, }, data: { - schema: SCHEMA_SEARCH_MODEL_VERSION_8, + schema: SCHEMA_SEARCH_MODEL_VERSION_10, }, }, out: { @@ -71,7 +71,7 @@ export const serviceDefinition: ServicesDefinition = { schema: savedSearchUpdateOptionsSchema, }, data: { - schema: SCHEMA_SEARCH_MODEL_VERSION_8, + schema: SCHEMA_SEARCH_MODEL_VERSION_10, }, }, }, diff --git a/src/platform/plugins/shared/saved_search/server/saved_objects/schema.ts b/src/platform/plugins/shared/saved_search/server/saved_objects/schema.ts index 03ebdf0f8f844..8e665c6ac8636 100644 --- a/src/platform/plugins/shared/saved_search/server/saved_objects/schema.ts +++ b/src/platform/plugins/shared/saved_search/server/saved_objects/schema.ts @@ -202,5 +202,31 @@ export const SCHEMA_SEARCH_MODEL_VERSION_9_SO_API_WORKAROUND = schema.object({ tabs: schema.maybe(tabsV8), }); -export type DiscoverSessionTabAttributes = TypeOf; -export type DiscoverSessionTab = TypeOf; +// Version 10 tabs use the same attributes as version 8 +const DISCOVER_SESSION_TAB_ATTRIBUTES_VERSION_10 = DISCOVER_SESSION_TAB_ATTRIBUTES_VERSION_8; + +const SCHEMA_DISCOVER_SESSION_TAB_VERSION_10 = SCHEMA_DISCOVER_SESSION_TAB_VERSION_8; + +// We need to extend from the base props rather than the full schema to avoid the same error as version 8 +const SCHEMA_SEARCH_MODEL_VERSION_10_EXTENDED = SCHEMA_SEARCH_MODEL_VERSION_8.extends({ + projectRouting: schema.maybe(schema.nullable(schema.string())), + tabs: schema.arrayOf(SCHEMA_DISCOVER_SESSION_TAB_VERSION_10, { minSize: 1 }), +}); + +const { tabs: tabsV10, ...restV10Props } = SCHEMA_SEARCH_MODEL_VERSION_10_EXTENDED.getPropSchemas(); + +export const SCHEMA_SEARCH_MODEL_VERSION_10 = schema.object({ + ...restV10Props, + tabs: tabsV10, +}); + +// Schema that works around saved objects API issues as in version 9 +export const SCHEMA_SEARCH_MODEL_VERSION_10_SO_API_WORKAROUND = schema.object({ + ...restV10Props, + tabs: schema.maybe(tabsV10), +}); + +export type DiscoverSessionTabAttributes = TypeOf< + typeof DISCOVER_SESSION_TAB_ATTRIBUTES_VERSION_10 +>; +export type DiscoverSessionTab = TypeOf; diff --git a/src/platform/plugins/shared/saved_search/server/saved_objects/search.ts b/src/platform/plugins/shared/saved_search/server/saved_objects/search.ts index f99c0c83932c9..dbad65ff13257 100644 --- a/src/platform/plugins/shared/saved_search/server/saved_objects/search.ts +++ b/src/platform/plugins/shared/saved_search/server/saved_objects/search.ts @@ -24,6 +24,7 @@ import { SCHEMA_SEARCH_MODEL_VERSION_7, SCHEMA_SEARCH_MODEL_VERSION_8, SCHEMA_SEARCH_MODEL_VERSION_9_SO_API_WORKAROUND, + SCHEMA_SEARCH_MODEL_VERSION_10_SO_API_WORKAROUND, } from './schema'; export function getSavedSearchObjectType( @@ -122,6 +123,16 @@ export function getSavedSearchObjectType( create: SCHEMA_SEARCH_MODEL_VERSION_9_SO_API_WORKAROUND, }, }, + 10: { + changes: [], + schemas: { + forwardCompatibility: SCHEMA_SEARCH_MODEL_VERSION_10_SO_API_WORKAROUND.extends( + {}, + { unknowns: 'ignore' } + ), + create: SCHEMA_SEARCH_MODEL_VERSION_10_SO_API_WORKAROUND, + }, + }, }, mappings: { dynamic: false, diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index de444ab3434e0..b620022267b86 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -2683,7 +2683,6 @@ "discover.topNav.openSearchPanel.manageSearchesButtonLabel": "Discover-Sitzungen verwalten", "discover.topNav.openSearchPanel.noSearchesFoundDescription": "Keine passenden Discover-Sitzungen gefunden.", "discover.topNav.openSearchPanel.openSearchTitle": "Discover-Sitzung öffnen", - "discover.topNav.saveModal.storeTimeWithSearchToggleDescription": "Aktualisieren Sie den Zeitfilter und das Aktualisierungsintervall auf die aktuelle Auswahl, wenn Sie diese Sitzung verwenden.", "discover.topNav.saveModal.storeTimeWithSearchToggleLabel": "Speichern Sie die Zeit mit der Discover-Sitzung", "discover.topNav.solutionsViewBadge.canManageSpacesDescription": "Wir haben Discover verbessert, damit sich Ihre Ansicht an das anpasst, was Sie erkunden. Wählen Sie in Ihren Space-Einstellungen „Observability“ oder „Security“ als „Lösungsansicht“.", "discover.topNav.solutionsViewBadge.cannotManageSpacesDescription": "Wir haben Discover verbessert, damit es sich nahtlos an das anpasst, was Sie erkunden. Wählen Sie Observability oder Security als „Lösungsansicht“ aus – bitten Sie Ihren Administrator, dies in den Bereichseinstellungen festzulegen.", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 8fad2e096adb1..af1c3511484b4 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -2702,7 +2702,6 @@ "discover.topNav.openSearchPanel.manageSearchesButtonLabel": "Gérer les sessions Discover", "discover.topNav.openSearchPanel.noSearchesFoundDescription": "Aucune session Discover correspondante n'a été trouvée.", "discover.topNav.openSearchPanel.openSearchTitle": "Ouvrir la session Discover", - "discover.topNav.saveModal.storeTimeWithSearchToggleDescription": "Mettez à jour le filtre temporel et actualisez l'intervalle pour afficher la sélection actuelle lors de l'utilisation de cette session.", "discover.topNav.saveModal.storeTimeWithSearchToggleLabel": "Stocker la durée avec la session Discover", "discover.topNav.solutionsViewBadge.canManageSpacesDescription": "Nous avons amélioré Discover afin que votre vue s'adapte à ce que vous explorez. Choisissez Observability ou Security comme \"vue de solution\" dans les paramètres de votre espace.", "discover.topNav.solutionsViewBadge.cannotManageSpacesDescription": "Nous avons amélioré Discover pour s'adapter facilement à votre exploration. Sélectionnez Observability ou Security comme \"vue de solution\". Demandez à votre administrateur de régler cette option dans les paramètres de l'espace.", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 6bcc9f662ff1a..78386b912c609 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -2702,7 +2702,6 @@ "discover.topNav.openSearchPanel.manageSearchesButtonLabel": "Discoverセッションを管理", "discover.topNav.openSearchPanel.noSearchesFoundDescription": "一致するDiscoverセッションが見つかりませんでした。", "discover.topNav.openSearchPanel.openSearchTitle": "Discoverセッションを開く", - "discover.topNav.saveModal.storeTimeWithSearchToggleDescription": "このセッションを使用するときには、時間フィルターを更新し、現在の選択に合わせて間隔を更新します。", "discover.topNav.saveModal.storeTimeWithSearchToggleLabel": "Discoverセッションと時間を保存", "discover.topNav.solutionsViewBadge.canManageSpacesDescription": "探索している内容に合わせて表示されるようにDiscoverが改善されました。スペース設定で[ソリューションビュー]として[オブザーバビリティ]または[セキュリティ]を選択します。", "discover.topNav.solutionsViewBadge.cannotManageSpacesDescription": "探索している内容に合わせてシームレスに表示されるようにDiscoverが強化されました。[ソリューションビュー]としてオブザーバビリティまたはSecurityを選択します。スペースで設定するには、管理者に依頼してください。", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 4e5a62c6b209e..dc88925234c79 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -2695,7 +2695,6 @@ "discover.topNav.openSearchPanel.manageSearchesButtonLabel": "管理 Discover 会话", "discover.topNav.openSearchPanel.noSearchesFoundDescription": "找不到匹配的 Discover 会话。", "discover.topNav.openSearchPanel.openSearchTitle": "打开 Discover 会话", - "discover.topNav.saveModal.storeTimeWithSearchToggleDescription": "在使用此会话时更新时间筛选并将时间间隔刷新到当前选择。", "discover.topNav.saveModal.storeTimeWithSearchToggleLabel": "将时间与 Discover 会话一起存储", "discover.topNav.solutionsViewBadge.canManageSpacesDescription": "我们改进了 Discover 以便您的视图适应您所浏览的内容。在工作区设置中选择 Observability 或 Security 作为“解决方案视图”。", "discover.topNav.solutionsViewBadge.cannotManageSpacesDescription": "我们增强了 Discover 以便无缝适应您所浏览的内容。选择 Observability 或 Security 作为“解决方案视图” — 要求管理员在工作区设置中进行设置。", From 72b1b655650e0aed4af2103b8874f996ad5a13f3 Mon Sep 17 00:00:00 2001 From: mbondyra Date: Wed, 3 Dec 2025 12:20:13 +0100 Subject: [PATCH 2/7] null/undefined cleanup --- .../cps_usage_overrides_badge.tsx | 21 +- .../public/__mocks__/discover_state.mock.ts | 2 - .../state_management/discover_state.test.ts | 10 +- .../actions/save_discover_session.test.ts | 3 +- .../utils/serialization_utils.test.ts | 61 +---- .../embeddable/utils/serialization_utils.ts | 12 +- .../common/saved_searches_utils.ts | 3 +- .../common/service/get_discover_session.ts | 4 +- .../common/service/get_saved_searches.test.ts | 2 + .../service/saved_searches_utils.test.ts | 5 +- .../common/service/saved_searches_utils.ts | 3 +- .../shared/saved_search/common/types.ts | 1 + .../public/service/save_discover_session.ts | 9 +- .../service/save_saved_searches.test.ts | 232 ++++++++---------- .../public/service/saved_searches_service.ts | 25 +- .../public/service/to_saved_search.test.ts | 2 + 16 files changed, 152 insertions(+), 243 deletions(-) diff --git a/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/cps_usage_overrides_badge.tsx b/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/cps_usage_overrides_badge.tsx index 79d7ff93d6b35..277db9c416101 100644 --- a/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/cps_usage_overrides_badge.tsx +++ b/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/cps_usage_overrides_badge.tsx @@ -33,8 +33,6 @@ export class CpsUsageOverridesBadge public order = 8; public getDisplayName({ embeddable }: EmbeddableApiContext) { - if (!this.hasOverride(embeddable)) throw new IncompatibleActionError(); - if (!apiPublishesProjectRouting(embeddable)) { throw new IncompatibleActionError(); } @@ -100,13 +98,13 @@ export class CpsUsageOverridesBadge return ( setIsPopoverOpen(!isPopoverOpen)} style={{ cursor: 'pointer' }} data-test-subj="cpsUsageOverridesBadgeButton" > {badgeLabel} - + } isOpen={isPopoverOpen} closePopover={() => setIsPopoverOpen(false)} @@ -196,20 +194,9 @@ export class CpsUsageOverridesBadge } private hasOverride(embeddable: unknown): boolean { - if ( - !apiPublishesProjectRouting(embeddable) || - !apiHasParentApi(embeddable) || - !apiPublishesProjectRouting(embeddable.parentApi) - ) { + if (!apiPublishesProjectRouting(embeddable)) { return false; } - - const embeddableProjectRouting = embeddable.projectRouting$.value; - const parentProjectRouting = embeddable.parentApi.projectRouting$.value; - - // Only show badge if embeddable has an explicit (non-undefined) override that differs from dashboard - return ( - embeddableProjectRouting !== undefined && embeddableProjectRouting !== parentProjectRouting - ); + return embeddable.projectRouting$.value !== undefined; } } diff --git a/src/platform/plugins/shared/discover/public/__mocks__/discover_state.mock.ts b/src/platform/plugins/shared/discover/public/__mocks__/discover_state.mock.ts index 5fceb3bec8c76..dd165e55fa057 100644 --- a/src/platform/plugins/shared/discover/public/__mocks__/discover_state.mock.ts +++ b/src/platform/plugins/shared/discover/public/__mocks__/discover_state.mock.ts @@ -289,8 +289,6 @@ export function getDiscoverStateMock({ }), ...additionalPersistedTabs, ], - projectRouting: - finalSavedSearch.projectRouting === null ? undefined : finalSavedSearch.projectRouting, } : undefined; const mockUserId = 'mockUserId'; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.test.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.test.ts index 0242627c4fb57..acd049cfd3037 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.test.ts @@ -909,10 +909,7 @@ describe('Discover state', () => { services: mockServices, }), ], - projectRouting: - savedSearchWithDefaults.projectRouting === null - ? undefined - : savedSearchWithDefaults.projectRouting, + projectRouting: savedSearchWithDefaults.projectRouting ?? undefined, }); await state.internalState.dispatch( internalStateActions.initializeTabs({ discoverSessionId: savedSearchWithDefaults.id }) @@ -967,10 +964,7 @@ describe('Discover state', () => { services: mockServices, }), ], - projectRouting: - savedSearchWithDefaults.projectRouting === null - ? undefined - : savedSearchWithDefaults.projectRouting, + projectRouting: savedSearchWithDefaults.projectRouting ?? undefined, }); await state.internalState.dispatch( internalStateActions.initializeTabs({ discoverSessionId: savedSearchWithDefaults.id }) diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.test.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.test.ts index 8b216ceb84c25..2b3df291b9f6b 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.test.ts @@ -53,8 +53,7 @@ const setup = ({ ...discoverSession, id: discoverSession.id ?? 'new-session', managed: false, - projectRouting: - discoverSession.projectRouting === null ? undefined : discoverSession.projectRouting, + projectRouting: discoverSession.projectRouting ?? undefined, }) ); const dataViewCreateSpy = jest.spyOn(services.dataViews, 'create'); diff --git a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts index 57d462609d748..f0bcad2ed865f 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts @@ -153,65 +153,6 @@ describe('Serialization utils', () => { expect(deserializedState.title).toEqual('test panel title'); expect(deserializedState.sort).toEqual([['order_date', 'asc']]); }); - - test('by reference with null projectRouting should transform to undefined', async () => { - const baseSearch = await discoverServiceMock.savedSearch.byValueToSavedSearch( - { - attributes: mockedSavedSearchAttributes, - }, - true - ); - - discoverServiceMock.savedSearch.get = jest.fn().mockResolvedValue({ - ...baseSearch, - savedObjectId: 'savedSearch', - projectRouting: null, - }); - - const serializedState: SerializedPanelState = { - rawState: { - savedObjectId: 'savedSearch', - }, - references: [], - }; - - const deserializedState = await deserializeState({ - serializedState, - discoverServices: discoverServiceMock, - }); - - expect(deserializedState.projectRouting).toBeUndefined(); - expect('projectRouting' in deserializedState).toBe(false); - }); - - test('by reference with projectRouting should preserve it', async () => { - const baseSearch = await discoverServiceMock.savedSearch.byValueToSavedSearch( - { - attributes: mockedSavedSearchAttributes, - }, - true - ); - - discoverServiceMock.savedSearch.get = jest.fn().mockResolvedValue({ - ...baseSearch, - savedObjectId: 'savedSearch', - projectRouting: 'ALL', - }); - - const serializedState: SerializedPanelState = { - rawState: { - savedObjectId: 'savedSearch', - }, - references: [], - }; - - const deserializedState = await deserializeState({ - serializedState, - discoverServices: discoverServiceMock, - }); - - expect(deserializedState.projectRouting).toBe('ALL'); - }); }); describe('serialize state', () => { @@ -223,6 +164,7 @@ describe('Serialization utils', () => { ...mockedSavedSearchAttributes, managed: false, searchSource, + projectRouting: undefined, }; const serializedState = serializeState({ @@ -269,6 +211,7 @@ describe('Serialization utils', () => { ...mockedSavedSearchAttributes, managed: false, searchSource, + projectRouting: undefined, }; test('equal state', () => { diff --git a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts index 67953b6e59de1..683d661dbf080 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts @@ -47,12 +47,8 @@ export const deserializeState = async ({ // what is this?? const rawSavedObjectAttributes = pick(so, EDITABLE_SAVED_SEARCH_KEYS); const savedObjectOverride = pick(serializedState.rawState, EDITABLE_SAVED_SEARCH_KEYS); - // Filter out null projectRouting for type safety (runtime already filtered by fromSavedSearchAttributes) - const { projectRouting, ...soWithoutTimeRangeAndProjectRouting } = omit(so, 'timeRange'); return { - ...soWithoutTimeRangeAndProjectRouting, - // Only include projectRouting if it's not null (SearchEmbeddableRuntimeState doesn't allow null) - ...(projectRouting !== null && projectRouting !== undefined && { projectRouting }), + ...omit(so, 'timeRange'), savedObjectId, savedObjectTitle: so.title, savedObjectDescription: so.description, @@ -71,12 +67,8 @@ export const deserializeState = async ({ serializedState.rawState as SearchEmbeddableByValueState, true ); - // Filter out null projectRouting for type safety (runtime already filtered by fromSavedSearchAttributes) - const { projectRouting, ...savedSearchWithoutProjectRouting } = savedSearch; return { - ...savedSearchWithoutProjectRouting, - // Only include projectRouting if it's not null (SearchEmbeddableRuntimeState doesn't allow null) - ...(projectRouting !== null && projectRouting !== undefined && { projectRouting }), + ...savedSearch, ...panelState, nonPersistedDisplayOptions: serializedState.rawState.nonPersistedDisplayOptions, }; diff --git a/src/platform/plugins/shared/saved_search/common/saved_searches_utils.ts b/src/platform/plugins/shared/saved_search/common/saved_searches_utils.ts index 02a973ad7eaee..69f771140f6c2 100644 --- a/src/platform/plugins/shared/saved_search/common/saved_searches_utils.ts +++ b/src/platform/plugins/shared/saved_search/common/saved_searches_utils.ts @@ -50,8 +50,7 @@ export const fromSavedSearchAttributes = < visContext: attributes.visContext, controlGroupJson: attributes.controlGroupJson, density: attributes.density, - // Only include projectRouting if it's not null/undefined (saved state allows null, runtime doesn't) - ...(projectRouting !== null && projectRouting !== undefined && { projectRouting }), + projectRouting: projectRouting ?? undefined, tabs, managed, } as ReturnType; diff --git a/src/platform/plugins/shared/saved_search/common/service/get_discover_session.ts b/src/platform/plugins/shared/saved_search/common/service/get_discover_session.ts index e5a92acbc7c2c..d751c6319bf24 100644 --- a/src/platform/plugins/shared/saved_search/common/service/get_discover_session.ts +++ b/src/platform/plugins/shared/saved_search/common/service/get_discover_session.ts @@ -50,9 +50,7 @@ export const getDiscoverSession = async ( visContext: tab.attributes.visContext, controlGroupJson: tab.attributes.controlGroupJson, })), - // Session-level projectRouting - normalize null to undefined for runtime usage - projectRouting: - so.item.attributes.projectRouting === null ? undefined : so.item.attributes.projectRouting, + projectRouting: so.item.attributes.projectRouting ?? undefined, managed: Boolean(so.item.managed), tags: deps.savedObjectsTagging ? deps.savedObjectsTagging.ui.getTagIdsFromReferences(so.item.references) diff --git a/src/platform/plugins/shared/saved_search/common/service/get_saved_searches.test.ts b/src/platform/plugins/shared/saved_search/common/service/get_saved_searches.test.ts index 425d67aecd55c..f8300eba92196 100644 --- a/src/platform/plugins/shared/saved_search/common/service/get_saved_searches.test.ts +++ b/src/platform/plugins/shared/saved_search/common/service/get_saved_searches.test.ts @@ -93,6 +93,7 @@ describe('getSavedSearch', () => { "id": "ccf1af80-2297-11ec-86e0-1155ffb9c7a7", "isTextBasedQuery": undefined, "managed": false, + "projectRouting": undefined, "references": Array [ Object { "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", @@ -244,6 +245,7 @@ describe('getSavedSearch', () => { "id": "ccf1af80-2297-11ec-86e0-1155ffb9c7a7", "isTextBasedQuery": true, "managed": false, + "projectRouting": undefined, "references": Array [ Object { "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", diff --git a/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.test.ts b/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.test.ts index 0e42040cd6528..6c640b2a4b61e 100644 --- a/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.test.ts +++ b/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.test.ts @@ -79,6 +79,7 @@ describe('saved_searches_utils', () => { "id": "id", "isTextBasedQuery": false, "managed": false, + "projectRouting": undefined, "references": Array [], "refreshInterval": undefined, "rowHeight": undefined, @@ -165,7 +166,7 @@ describe('saved_searches_utils', () => { }; const result = toSavedSearchAttributes(savedSearch, '{}'); - expect(result).toEqual({ + expect(result).toMatchObject({ kibanaSavedObjectMeta: { searchSourceJSON: '{}', }, @@ -178,6 +179,7 @@ describe('saved_searches_utils', () => { isTextBasedQuery: true, usesAdHocDataView: false, timeRestore: false, + projectRouting: null, tabs: [ { id: expect.any(String), @@ -193,6 +195,7 @@ describe('saved_searches_utils', () => { isTextBasedQuery: true, usesAdHocDataView: false, timeRestore: false, + projectRouting: null, }, }, ], diff --git a/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.ts b/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.ts index eb09e7db1a080..f6a542f97b44b 100644 --- a/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.ts +++ b/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.ts @@ -52,7 +52,8 @@ export const toSavedSearchAttributes = ( timeRestore: savedSearch.timeRestore ?? false, timeRange: savedSearch.timeRange ? pick(savedSearch.timeRange, ['from', 'to']) : undefined, refreshInterval: savedSearch.refreshInterval, - projectRouting: savedSearch.projectRouting ?? undefined, + // Convert undefined to null for storage (null explicitly signals "no value" in saved objects) + projectRouting: savedSearch.projectRouting ?? null, rowsPerPage: savedSearch.rowsPerPage, sampleSize: savedSearch.sampleSize, density: savedSearch.density, diff --git a/src/platform/plugins/shared/saved_search/common/types.ts b/src/platform/plugins/shared/saved_search/common/types.ts index 67161e448f764..4ba6a12b4908f 100644 --- a/src/platform/plugins/shared/saved_search/common/types.ts +++ b/src/platform/plugins/shared/saved_search/common/types.ts @@ -98,6 +98,7 @@ export type SavedSearch = Partial & { aliasPurpose?: SavedObjectsResolveResponse['alias_purpose']; errorJSON?: string; }; + projectRouting?: ProjectRouting; }; /** @internal **/ diff --git a/src/platform/plugins/shared/saved_search/public/service/save_discover_session.ts b/src/platform/plugins/shared/saved_search/public/service/save_discover_session.ts index 847f3bb30735a..fe7f01db71216 100644 --- a/src/platform/plugins/shared/saved_search/public/service/save_discover_session.ts +++ b/src/platform/plugins/shared/saved_search/public/service/save_discover_session.ts @@ -136,13 +136,9 @@ export const saveDiscoverSession = async ( ...tabs[0].attributes, sort: tabs[0].attributes.sort as SortOrder[], density: tabs[0].attributes.density as DataGridDensity, + projectRouting: discoverSession.projectRouting ?? null, }; - // Only include projectRouting if explicitly provided (even if null to clear) - if ('projectRouting' in discoverSession) { - attributes.projectRouting = discoverSession.projectRouting ?? null; - } - const references = savedObjectsTagging ? savedObjectsTagging.ui.updateTagsReferences(tabReferences, discoverSession.tags ?? []) : tabReferences; @@ -156,8 +152,7 @@ export const saveDiscoverSession = async ( return { ...discoverSession, - projectRouting: - discoverSession.projectRouting === null ? undefined : discoverSession.projectRouting, + projectRouting: discoverSession.projectRouting ?? undefined, id, references, managed: false, diff --git a/src/platform/plugins/shared/saved_search/public/service/save_saved_searches.test.ts b/src/platform/plugins/shared/saved_search/public/service/save_saved_searches.test.ts index ae2ab38213d8e..d8cda3c045e6e 100644 --- a/src/platform/plugins/shared/saved_search/public/service/save_saved_searches.test.ts +++ b/src/platform/plugins/shared/saved_search/public/service/save_saved_searches.test.ts @@ -116,48 +116,42 @@ describe('saveSavedSearch', () => { await saveSavedSearch(savedSearch, {}, cmApi, undefined); - expect(cmApi.create).toHaveBeenCalledWith({ - contentTypeId: 'search', - data: { - breakdownField: undefined, - columns: [], - description: '', - grid: {}, - hideAggregatedPreview: undefined, - hideChart: false, - isTextBasedQuery: false, - kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, - refreshInterval: undefined, - rowHeight: undefined, - headerRowHeight: undefined, - rowsPerPage: undefined, - sampleSize: undefined, - sort: [], - timeRange: undefined, - timeRestore: false, - title: 'title', - usesAdHocDataView: undefined, - viewMode: undefined, - tabs: [ - { - id: expect.any(String), - label: 'Untitled', - attributes: { - kibanaSavedObjectMeta: { - searchSourceJSON: '{}', - }, - sort: [], - columns: [], - grid: {}, - hideChart: false, - isTextBasedQuery: false, - timeRestore: false, + expect(cmApi.create).toHaveBeenCalledWith( + expect.objectContaining({ + contentTypeId: 'search', + data: expect.objectContaining({ + columns: [], + description: '', + grid: {}, + hideChart: false, + isTextBasedQuery: false, + kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, + projectRouting: null, + sort: [], + timeRestore: false, + title: 'title', + tabs: [ + { + id: expect.any(String), + label: 'Untitled', + attributes: expect.objectContaining({ + kibanaSavedObjectMeta: { + searchSourceJSON: '{}', + }, + sort: [], + columns: [], + grid: {}, + hideChart: false, + isTextBasedQuery: false, + timeRestore: false, + projectRouting: null, + }), }, - }, - ], - }, - options: { references: [] }, - }); + ], + }), + options: { references: [] }, + }) + ); }); test('should call savedObjectsClient.update for saving existing search', async () => { @@ -169,49 +163,43 @@ describe('saveSavedSearch', () => { await saveSavedSearch(savedSearch, {}, cmApi, undefined); - expect(cmApi.update).toHaveBeenCalledWith({ - contentTypeId: 'search', - data: { - breakdownField: undefined, - columns: [], - description: '', - grid: {}, - hideAggregatedPreview: undefined, - isTextBasedQuery: false, - hideChart: false, - kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, - refreshInterval: undefined, - rowHeight: undefined, - headerRowHeight: undefined, - rowsPerPage: undefined, - sampleSize: undefined, - timeRange: undefined, - sort: [], - title: 'title', - timeRestore: false, - usesAdHocDataView: undefined, - viewMode: undefined, - tabs: [ - { - id: expect.any(String), - label: 'Untitled', - attributes: { - kibanaSavedObjectMeta: { - searchSourceJSON: '{}', - }, - sort: [], - columns: [], - grid: {}, - hideChart: false, - isTextBasedQuery: false, - timeRestore: false, + expect(cmApi.update).toHaveBeenCalledWith( + expect.objectContaining({ + contentTypeId: 'search', + data: expect.objectContaining({ + columns: [], + description: '', + grid: {}, + isTextBasedQuery: false, + hideChart: false, + kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, + projectRouting: null, + sort: [], + title: 'title', + timeRestore: false, + tabs: [ + { + id: expect.any(String), + label: 'Untitled', + attributes: expect.objectContaining({ + kibanaSavedObjectMeta: { + searchSourceJSON: '{}', + }, + sort: [], + columns: [], + grid: {}, + hideChart: false, + isTextBasedQuery: false, + timeRestore: false, + projectRouting: null, + }), }, - }, - ], - }, - id: 'id', - options: { references: [] }, - }); + ], + }), + id: 'id', + options: { references: [] }, + }) + ); }); test('should call savedObjectsTagging.ui.updateTagsReferences', async () => { @@ -237,48 +225,42 @@ describe('saveSavedSearch', () => { [], ['tag-1', 'tag-2'] ); - expect(cmApi.update).toHaveBeenCalledWith({ - contentTypeId: 'search', - data: { - breakdownField: undefined, - columns: [], - description: '', - grid: {}, - hideAggregatedPreview: undefined, - hideChart: false, - isTextBasedQuery: false, - kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, - refreshInterval: undefined, - rowHeight: undefined, - headerRowHeight: undefined, - rowsPerPage: undefined, - sampleSize: undefined, - sort: [], - timeRange: undefined, - timeRestore: false, - title: 'title', - usesAdHocDataView: undefined, - viewMode: undefined, - tabs: [ - { - id: expect.any(String), - label: 'Untitled', - attributes: { - kibanaSavedObjectMeta: { - searchSourceJSON: '{}', - }, - sort: [], - columns: [], - grid: {}, - hideChart: false, - isTextBasedQuery: false, - timeRestore: false, + expect(cmApi.update).toHaveBeenCalledWith( + expect.objectContaining({ + contentTypeId: 'search', + data: expect.objectContaining({ + columns: [], + description: '', + grid: {}, + hideChart: false, + isTextBasedQuery: false, + kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, + projectRouting: null, + sort: [], + timeRestore: false, + title: 'title', + tabs: [ + { + id: expect.any(String), + label: 'Untitled', + attributes: expect.objectContaining({ + kibanaSavedObjectMeta: { + searchSourceJSON: '{}', + }, + sort: [], + columns: [], + grid: {}, + hideChart: false, + isTextBasedQuery: false, + timeRestore: false, + projectRouting: null, + }), }, - }, - ], - }, - id: 'id', - options: { references: ['tag-1', 'tag-2'] }, - }); + ], + }), + id: 'id', + options: { references: ['tag-1', 'tag-2'] }, + }) + ); }); }); diff --git a/src/platform/plugins/shared/saved_search/public/service/saved_searches_service.ts b/src/platform/plugins/shared/saved_search/public/service/saved_searches_service.ts index c8754708ccf1a..8aa8f38bc1a85 100644 --- a/src/platform/plugins/shared/saved_search/public/service/saved_searches_service.ts +++ b/src/platform/plugins/shared/saved_search/public/service/saved_searches_service.ts @@ -39,15 +39,27 @@ export interface SavedSearchesServiceDeps { export class SavedSearchesService { constructor(private deps: SavedSearchesServiceDeps) {} - get = ( + get = async ( savedSearchId: string, serialized?: Serialized ): Promise => { - return getSavedSearch(savedSearchId, createGetSavedSearchDeps(this.deps), serialized); + const result = await getSavedSearch( + savedSearchId, + createGetSavedSearchDeps(this.deps), + serialized + ); + return { + ...result, + projectRouting: result.projectRouting ?? undefined, + } as Serialized extends true ? SerializableSavedSearch : SavedSearch; }; - getDiscoverSession = (discoverSessionId: string) => { - return getDiscoverSession(discoverSessionId, createGetSavedSearchDeps(this.deps)); + getDiscoverSession = async (discoverSessionId: string) => { + const result = await getDiscoverSession(discoverSessionId, createGetSavedSearchDeps(this.deps)); + return { + ...result, + projectRouting: result.projectRouting ?? undefined, + }; }; getAll = async () => { @@ -90,11 +102,12 @@ export class SavedSearchesService { ); }; - saveDiscoverSession = ( + saveDiscoverSession = async ( discoverSession: SaveDiscoverSessionParams, options: SaveDiscoverSessionOptions = {} ) => { const { contentManagement, savedObjectsTaggingOss } = this.deps; + return saveDiscoverSession( discoverSession, options, @@ -114,7 +127,7 @@ export class SavedSearchesService { }); }; - byValueToSavedSearch = ( + byValueToSavedSearch = async ( result: SavedSearchUnwrapResult, serialized?: Serialized ): Promise => { diff --git a/src/platform/plugins/shared/saved_search/public/service/to_saved_search.test.ts b/src/platform/plugins/shared/saved_search/public/service/to_saved_search.test.ts index f4362f1ee7317..132427e7be296 100644 --- a/src/platform/plugins/shared/saved_search/public/service/to_saved_search.test.ts +++ b/src/platform/plugins/shared/saved_search/public/service/to_saved_search.test.ts @@ -74,6 +74,7 @@ describe('toSavedSearch', () => { "id": undefined, "isTextBasedQuery": false, "managed": false, + "projectRouting": undefined, "references": Array [ Object { "id": "1", @@ -207,6 +208,7 @@ describe('toSavedSearch', () => { "id": undefined, "isTextBasedQuery": false, "managed": false, + "projectRouting": undefined, "references": Array [ Object { "id": "1", From 301e67c36a60d27f0c6433a36af36794d8e11960 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:04:46 +0000 Subject: [PATCH 3/7] Changes from node scripts/jest_integration -u src/core/server/integration_tests/ci_checks --- .../saved_objects/check_registered_types.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 3f228cf27fab3..0ca1c1b112119 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -163,7 +163,7 @@ describe('checking migration metadata changes on all registered SO types', () => "risk-engine-configuration": "9d54f733fb2bd08978c7059d71e77741574dc2616823745501742d34816a408c", "rules-settings": "436a36535b5d57ea1f7cbaaa37887ed5ddac8c3dea30c9fd98b3931ae87dfe1a", "sample-data-telemetry": "4c102e89bdcaee1ccc887d1709c7e176c05f25b4c5ac14c3d013b58fbfd806ac", - "search": "876ae1abc39d4026e854de185bbec72bf630bac33541de292c42fc8abf9df4ab", + "search": "6b482417fcbb8dad21a89c4a05b6701bbb39cc96933a8e82d8317e20e7f2eec7", "search-session": "95e62da1c06afde503c7d12efebc7df2102b9cb8bead1f50ca5c6ab8f4b67c26", "search-telemetry": "c152fc7e66d5ac7907e81c0926be9c219a15181e10b418b2fbb86bab2760627c", "search_playground": "97895cb5356dd7dad771b6d72b5c236c7c229c012545d56bf6e849984512c9b1", @@ -1026,8 +1026,9 @@ describe('checking migration metadata changes on all registered SO types', () => "search|global: ce649a79d99c5ff5eb68d544635428ef87946d84", "search|mappings: 432d4dfdb5a33ce29d00ccdcfcda70d7c5f94b52", "search|schemas: 8d6477e08dfdf20335752a69994646f9da90741f", - "search|10.9.0: 479252675efede136671875406da242ba00df105c02fcab784c22c241bc2e152", - "search|10.8.0: 6f23e64134fc13f4b749afdff5ab636791d9ce58eb585c361fd70a1619915048", + "search|10.10.0: 479252675efede136671875406da242ba00df105c02fcab784c22c241bc2e152", + "search|10.9.0: 557d8a40f3cd758fb4da9afba44e827a8c18b63ba140af871cf4a815f8e5e869", + "search|10.8.0: 76274f35cc139d5e208236bb92c859dd29e27ade181950a9f0bc3e95220c86dc", "search|10.7.0: 03bcc899ac7be8e0a88520ae8fc091fc6ea37b231848dcbc0119b7425f36dd0e", "search|10.6.0: 7b3028dd2f4dac78ce93be51bb9b85f1b44bdd70181d078fd1867ef5366bb76c", "search|10.5.0: 9ca367bf4f8f09dc59bd48d8fd109ee8db2426305e44aee7e35fed09001bf2dc", @@ -1360,7 +1361,7 @@ describe('checking migration metadata changes on all registered SO types', () => "risk-engine-configuration": "10.4.0", "rules-settings": "10.1.0", "sample-data-telemetry": "10.0.0", - "search": "10.9.0", + "search": "10.10.0", "search-session": "10.0.0", "search-telemetry": "10.0.0", "search_playground": "10.1.0", @@ -1508,7 +1509,7 @@ describe('checking migration metadata changes on all registered SO types', () => "risk-engine-configuration": "10.4.0", "rules-settings": "10.1.0", "sample-data-telemetry": "0.0.0", - "search": "10.9.0", + "search": "10.10.0", "search-session": "8.6.0", "search-telemetry": "7.12.0", "search_playground": "10.1.0", From 71cfa9d6b7fdfc39676707f4e492f18c241f9ea6 Mon Sep 17 00:00:00 2001 From: mbondyra Date: Wed, 3 Dec 2025 14:30:11 +0100 Subject: [PATCH 4/7] null/undefined cleanup --- .../cps_usage_overrides_badge.tsx | 99 ++++++------------- 1 file changed, 31 insertions(+), 68 deletions(-) diff --git a/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/cps_usage_overrides_badge.tsx b/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/cps_usage_overrides_badge.tsx index 277db9c416101..a9583095b4410 100644 --- a/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/cps_usage_overrides_badge.tsx +++ b/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/cps_usage_overrides_badge.tsx @@ -6,7 +6,6 @@ * your election, the "Elastic License 2.0", the "GNU Affero General Public * License v3.0 only", or the "Server Side Public License, v 1". */ - import type { Action, ActionExecutionMeta, @@ -14,7 +13,7 @@ import type { } from '@kbn/ui-actions-plugin/public'; import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; import React, { useState } from 'react'; -import { EuiPopover, EuiText, EuiButton, EuiSpacer } from '@elastic/eui'; +import { EuiPopover, EuiText, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import type { EmbeddableApiContext } from '@kbn/presentation-publishing'; import { apiPublishesProjectRouting, apiHasParentApi } from '@kbn/presentation-publishing'; @@ -58,24 +57,10 @@ export class CpsUsageOverridesBadge } const overrideValue = embeddable.projectRouting$.value; - const dashboardValue = - apiHasParentApi(embeddable) && apiPublishesProjectRouting(embeddable.parentApi) - ? embeddable.parentApi.projectRouting$.value - : undefined; - const badgeLabel = i18n.translate('presentationPanel.badge.cpsUsageOverrides.label', { defaultMessage: 'CPS overrides', }); - const formatProjectRoutingValue = (value: string | undefined) => { - if (value === 'ALL') { - return i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.allProjects', { - defaultMessage: 'All projects', - }); - } - return value; - }; - const handleEditClick = async () => { setIsPopoverOpen(false); try { @@ -97,64 +82,42 @@ export class CpsUsageOverridesBadge return ( setIsPopoverOpen(!isPopoverOpen)} - style={{ cursor: 'pointer' }} - data-test-subj="cpsUsageOverridesBadgeButton" - > - {badgeLabel} - - } + button={} isOpen={isPopoverOpen} closePopover={() => setIsPopoverOpen(false)} anchorPosition="downCenter" + panelStyle={{ minWidth: 250 }} > - -

- - {i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.title', { - defaultMessage: 'CPS Scope Override', - })} - -

-

- - {i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.panelScope', { - defaultMessage: 'Panel scope:', - })} - - {formatProjectRoutingValue(overrideValue)} -
- - {i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.dashboardScope', { - defaultMessage: 'Dashboard scope:', - })} - {' '} - {formatProjectRoutingValue(dashboardValue) ?? - i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.notSet', { - defaultMessage: 'Not set', + + + + + {i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.title', { + defaultMessage: 'CPS Override', + })} + + + + + + {i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.editButton', { + defaultMessage: 'Edit', })} -

-

- {i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.description', { - defaultMessage: - "To use the dashboard's CPS scope, remove the override from panel settings.", - })} -

+ + + + + {overrideValue === 'ALL' + ? i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.allProjects', { + defaultMessage: 'All projects', + }) + : overrideValue} - - - {i18n.translate('presentationPanel.badge.cpsUsageOverrides.popover.editButton', { - defaultMessage: 'Edit panel configuration', - })} -
); }; From 52b04ba0999076dac903afc958c0e7738bcc6a60 Mon Sep 17 00:00:00 2001 From: mbondyra Date: Wed, 3 Dec 2025 14:57:53 +0100 Subject: [PATCH 5/7] null undefined more cleanups --- .../main/state_management/discover_state.ts | 8 +-- .../redux/actions/save_discover_session.ts | 9 +-- .../initialize_search_embeddable_api.tsx | 12 ++-- .../utils/serialization_utils.test.ts | 33 ----------- .../embeddable/utils/serialization_utils.ts | 2 +- .../saved_object_save_modal_origin.tsx | 55 ++----------------- .../public/service/save_discover_session.ts | 1 - .../public/service/saved_searches_service.ts | 24 ++------ 8 files changed, 23 insertions(+), 121 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts index 83edbd6db5e25..f3539adb3f085 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts @@ -569,15 +569,15 @@ export function getDiscoverStateContainer({ ); // Subscribe to CPS projectRouting changes (global subscription affects all tabs) - const cpsProjectRoutingSubscription = services.cps?.cpsManager - ?.getProjectRouting$() - .subscribe((cpsProjectRouting) => { + const cpsProjectRoutingSubscription = services.cps?.cpsManager?.getProjectRouting$().subscribe( + (cpsProjectRouting) => { const currentProjectRouting = internalState.getState().projectRouting; if (cpsProjectRouting !== currentProjectRouting) { internalState.dispatch(internalStateActions.setProjectRouting(cpsProjectRouting)); } - }); + } + ); const { start: startSyncingGlobalStateWithUrl, stop: stopSyncingGlobalStateWithUrl } = syncState({ diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.ts index e8d37cfee04de..6b5a998a9131e 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.ts @@ -213,16 +213,9 @@ export const saveDiscoverSession = createInternalStateAsyncThunk( description: newDescription, tabs: updatedTabs, tags: services.savedObjectsTagging ? newTags : state.persistedDiscoverSession?.tags, + projectRouting: newProjectRoutingRestore ? state.projectRouting : undefined, }; - // Handle projectRouting: only include if toggle is ON, or if we need to explicitly clear it - if (newProjectRoutingRestore) { - saveParams.projectRouting = state.projectRouting; - } else if (state.persistedDiscoverSession?.projectRouting !== undefined) { - // Explicitly clear if it existed before - saveParams.projectRouting = null; - } - const saveOptions: SaveDiscoverSessionOptions = { onTitleDuplicate, copyOnSave: newCopyOnSave, diff --git a/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx b/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx index 77276738dbf42..4901150abedd4 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx +++ b/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx @@ -90,7 +90,7 @@ export const initializeSearchEmbeddableApi = async ( ): Promise<{ api: PublishesWritableSavedSearch & PublishesWritableDataViews & - PublishesProjectRouting & + Partial & Partial; stateManager: SearchEmbeddableStateManager; anyStateChange$: Observable; @@ -118,9 +118,9 @@ export const initializeSearchEmbeddableApi = async ( const density$ = new BehaviorSubject(initialState.density); const sort$ = new BehaviorSubject(initialState.sort); const savedSearchViewMode$ = new BehaviorSubject(initialState.viewMode); - const projectRouting$ = new BehaviorSubject( - initialState.projectRouting ?? undefined - ); + const projectRouting$ = discoverServices.cps?.cpsManager + ? new BehaviorSubject(initialState.projectRouting ?? undefined) + : undefined; /** * This is the state that comes from the search source that needs individual publishing subjects for the API @@ -223,7 +223,7 @@ export const initializeSearchEmbeddableApi = async ( setQuery, canEditUnifiedSearch, setColumns, - projectRouting$, + ...(projectRouting$ ? { projectRouting$ } : {}), }, stateManager, anyStateChange$: onAnyStateChange.pipe(map(() => undefined)), @@ -250,7 +250,7 @@ export const initializeSearchEmbeddableApi = async ( headerRowHeight$.next(lastSaved?.headerRowHeight); savedSearchViewMode$.next(lastSaved?.viewMode); density$.next(lastSaved?.density); - projectRouting$.next(lastSaved?.projectRouting ?? undefined); + projectRouting$?.next(lastSaved?.projectRouting ?? undefined); }, }; }; diff --git a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts index f0bcad2ed865f..04a85a2d4083a 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts @@ -91,39 +91,6 @@ describe('Serialization utils', () => { expect(deserializedState.title).toEqual('test panel title'); }); - test('by value with null projectRouting should transform to undefined', async () => { - (serializedByValueState.rawState as SearchEmbeddableByValueState)!.attributes.projectRouting = - null; - - const deserializedState = await deserializeState({ - serializedState: serializedByValueState, - discoverServices: discoverServiceMock, - }); - - expect(deserializedState.projectRouting).toBeUndefined(); - expect('projectRouting' in deserializedState).toBe(false); - }); - - test('by value with projectRouting should preserve it', async () => { - // Mock byValueToSavedSearch to return projectRouting - discoverServiceMock.savedSearch.byValueToSavedSearch = jest.fn().mockResolvedValue({ - projectRouting: 'project1', - serializedSearchSource: {}, - title: 'test', - tabs: mockedSavedSearchAttributes.tabs, - }); - - (serializedByValueState.rawState as SearchEmbeddableByValueState)!.attributes.projectRouting = - 'test1'; - - const deserializedState = await deserializeState({ - serializedState: serializedByValueState, - discoverServices: discoverServiceMock, - }); - - expect(deserializedState.projectRouting).toBe('project1'); - }); - test('by reference', async () => { discoverServiceMock.savedSearch.get = jest.fn().mockReturnValue({ savedObjectId: 'savedSearch', diff --git a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts index 683d661dbf080..507dd9dc4711c 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts @@ -44,10 +44,10 @@ export const deserializeState = async ({ const { get } = discoverServices.savedSearch; const so = await get(savedObjectId, true); - // what is this?? const rawSavedObjectAttributes = pick(so, EDITABLE_SAVED_SEARCH_KEYS); const savedObjectOverride = pick(serializedState.rawState, EDITABLE_SAVED_SEARCH_KEYS); return { + // ignore the time range from the saved object - only global time range + panel time range matter ...omit(so, 'timeRange'), savedObjectId, savedObjectTitle: so.title, diff --git a/src/platform/plugins/shared/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx b/src/platform/plugins/shared/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx index 08f4fc26ffbc9..f079d077ecf8b 100644 --- a/src/platform/plugins/shared/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx +++ b/src/platform/plugins/shared/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx @@ -9,7 +9,7 @@ import React, { Fragment, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiFormRow, EuiSwitch, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { OnSaveProps, SaveModalState, SaveResult } from '.'; @@ -30,18 +30,11 @@ export interface OriginSaveModalProps { objectType: string; onClose: () => void; options?: React.ReactNode | ((state: SaveModalState) => React.ReactNode); - onSave: ( - props: OnSaveProps & { returnToOrigin: boolean; newProjectRoutingRestore?: boolean } - ) => Promise; - projectRoutingRestore?: boolean; - showStoreProjectRoutingOnSave?: boolean; + onSave: (props: OnSaveProps & { returnToOrigin: boolean }) => Promise; } export function SavedObjectSaveModalOrigin(props: OriginSaveModalProps) { const [returnToOriginMode, setReturnToOriginMode] = useState(Boolean(props.originatingApp)); - const [persistSelectedProjectRouting, setPersistSelectedProjectRouting] = useState( - props.projectRoutingRestore ?? false - ); const { documentInfo } = props; const returnLabel = i18n.translate('savedObjects.saveModalOrigin.returnToOriginLabel', { @@ -55,34 +48,8 @@ export function SavedObjectSaveModalOrigin(props: OriginSaveModalProps) { const sourceOptions = typeof props.options === 'function' ? props.options(state) : props.options; - const projectRoutingOptions = props.showStoreProjectRoutingOnSave ? ( - - - - setPersistSelectedProjectRouting(event.target.checked)} - label={ - - } - /> - - - - ) : null; - if (!props.originatingApp) { - return ( - - {sourceOptions} - {projectRoutingOptions} - - ); + return sourceOptions; } const origin = props.getAppNameFromId ? props.getAppNameFromId(props.originatingApp) || props.originatingApp @@ -96,7 +63,6 @@ export function SavedObjectSaveModalOrigin(props: OriginSaveModalProps) { return ( {sourceOptions} - {projectRoutingOptions} - {sourceOptions} - {projectRoutingOptions} - - ); + return sourceOptions; } }; const onModalSave = async (onSaveProps: OnSaveProps): Promise => { - return props.onSave({ - ...onSaveProps, - returnToOrigin: returnToOriginMode, - newProjectRoutingRestore: props.showStoreProjectRoutingOnSave - ? persistSelectedProjectRouting - : undefined, - }); + return props.onSave({ ...onSaveProps, returnToOrigin: returnToOriginMode }); }; const confirmButtonLabel = returnToOriginMode diff --git a/src/platform/plugins/shared/saved_search/public/service/save_discover_session.ts b/src/platform/plugins/shared/saved_search/public/service/save_discover_session.ts index fe7f01db71216..823ddadc2a4ed 100644 --- a/src/platform/plugins/shared/saved_search/public/service/save_discover_session.ts +++ b/src/platform/plugins/shared/saved_search/public/service/save_discover_session.ts @@ -24,7 +24,6 @@ export type SaveDiscoverSessionParams = Pick< 'title' | 'description' | 'tabs' | 'tags' > & Partial> & { - // projectRouting can be undefined (not provided), a value, or null (to explicitly clear) projectRouting?: DiscoverSession['projectRouting'] | null; }; diff --git a/src/platform/plugins/shared/saved_search/public/service/saved_searches_service.ts b/src/platform/plugins/shared/saved_search/public/service/saved_searches_service.ts index 8aa8f38bc1a85..4ff794dd7f7e4 100644 --- a/src/platform/plugins/shared/saved_search/public/service/saved_searches_service.ts +++ b/src/platform/plugins/shared/saved_search/public/service/saved_searches_service.ts @@ -39,27 +39,15 @@ export interface SavedSearchesServiceDeps { export class SavedSearchesService { constructor(private deps: SavedSearchesServiceDeps) {} - get = async ( + get = ( savedSearchId: string, serialized?: Serialized ): Promise => { - const result = await getSavedSearch( - savedSearchId, - createGetSavedSearchDeps(this.deps), - serialized - ); - return { - ...result, - projectRouting: result.projectRouting ?? undefined, - } as Serialized extends true ? SerializableSavedSearch : SavedSearch; + return getSavedSearch(savedSearchId, createGetSavedSearchDeps(this.deps), serialized); }; - getDiscoverSession = async (discoverSessionId: string) => { - const result = await getDiscoverSession(discoverSessionId, createGetSavedSearchDeps(this.deps)); - return { - ...result, - projectRouting: result.projectRouting ?? undefined, - }; + getDiscoverSession = (discoverSessionId: string) => { + return getDiscoverSession(discoverSessionId, createGetSavedSearchDeps(this.deps)); }; getAll = async () => { @@ -102,7 +90,7 @@ export class SavedSearchesService { ); }; - saveDiscoverSession = async ( + saveDiscoverSession = ( discoverSession: SaveDiscoverSessionParams, options: SaveDiscoverSessionOptions = {} ) => { @@ -127,7 +115,7 @@ export class SavedSearchesService { }); }; - byValueToSavedSearch = async ( + byValueToSavedSearch = ( result: SavedSearchUnwrapResult, serialized?: Serialized ): Promise => { From 17616c6d3e794d5e8345e6fd25f9b2080e9b0faa Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:01:25 +0000 Subject: [PATCH 6/7] Changes from node scripts/eslint_all_files --no-cache --fix --- .../application/main/state_management/discover_state.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts index f3539adb3f085..83edbd6db5e25 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts @@ -569,15 +569,15 @@ export function getDiscoverStateContainer({ ); // Subscribe to CPS projectRouting changes (global subscription affects all tabs) - const cpsProjectRoutingSubscription = services.cps?.cpsManager?.getProjectRouting$().subscribe( - (cpsProjectRouting) => { + const cpsProjectRoutingSubscription = services.cps?.cpsManager + ?.getProjectRouting$() + .subscribe((cpsProjectRouting) => { const currentProjectRouting = internalState.getState().projectRouting; if (cpsProjectRouting !== currentProjectRouting) { internalState.dispatch(internalStateActions.setProjectRouting(cpsProjectRouting)); } - } - ); + }); const { start: startSyncingGlobalStateWithUrl, stop: stopSyncingGlobalStateWithUrl } = syncState({ From 0cd869900037dbfd32693a9bf3d256f64357f30e Mon Sep 17 00:00:00 2001 From: mbondyra Date: Wed, 3 Dec 2025 20:13:06 +0100 Subject: [PATCH 7/7] fixtures (?) --- .../__fixtures__/search/10.10.0.json | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/search/10.10.0.json diff --git a/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/search/10.10.0.json b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/search/10.10.0.json new file mode 100644 index 0000000000000..26a4b51f1af43 --- /dev/null +++ b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/search/10.10.0.json @@ -0,0 +1,50 @@ +{ + "10.9.0": [ + { + "title": "Test Search", + "description": "Test saved search", + "tabs": [ + { + "id": "tab-1", + "label": "Main Tab", + "attributes": { + "columns": ["field1", "field2"], + "sort": [["@timestamp", "desc"]], + "grid": {}, + "hideChart": false, + "isTextBasedQuery": false, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "rowHeight": 3, + "density": "normal" + } + } + ] + } + ], + "10.10.0": [ + { + "title": "Test Search", + "description": "Test saved search", + "tabs": [ + { + "id": "tab-1", + "label": "Main Tab", + "attributes": { + "columns": ["field1", "field2"], + "sort": [["@timestamp", "desc"]], + "grid": {}, + "hideChart": false, + "isTextBasedQuery": false, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "rowHeight": 3, + "density": "normal" + } + } + ] + } + ] +}