From f6af57df1702063d634a20bf5a482db343424ee1 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Wed, 25 Feb 2026 18:59:28 +0100 Subject: [PATCH 1/9] ui preparation for selecting time range when creating exports --- .../common/time_type_selection/index.ts | 10 + .../time_type_selection.test.tsx} | 8 +- .../time_type_selection.tsx} | 18 +- .../export_integrations.tsx | 30 +- .../public/components/share_context_menu.tsx | 4 +- .../components/tabs/link/link_content.tsx | 4 +- .../public/components/url_panel_content.tsx | 4 +- .../plugins/shared/share/public/index.ts | 2 +- .../public/services/share_menu_manager.tsx | 302 ++++++++++-------- .../plugins/shared/share/public/types.ts | 5 +- 10 files changed, 237 insertions(+), 150 deletions(-) create mode 100644 src/platform/plugins/shared/share/public/components/common/time_type_selection/index.ts rename src/platform/plugins/shared/share/public/components/{tabs/link/time_type_section.test.tsx => common/time_type_selection/time_type_selection.test.tsx} (96%) rename src/platform/plugins/shared/share/public/components/{tabs/link/time_type_section.tsx => common/time_type_selection/time_type_selection.tsx} (91%) diff --git a/src/platform/plugins/shared/share/public/components/common/time_type_selection/index.ts b/src/platform/plugins/shared/share/public/components/common/time_type_selection/index.ts new file mode 100644 index 0000000000000..e51ea3027a097 --- /dev/null +++ b/src/platform/plugins/shared/share/public/components/common/time_type_selection/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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". + */ + +export { TimeTypeSelection } from './time_type_selection'; diff --git a/src/platform/plugins/shared/share/public/components/tabs/link/time_type_section.test.tsx b/src/platform/plugins/shared/share/public/components/common/time_type_selection/time_type_selection.test.tsx similarity index 96% rename from src/platform/plugins/shared/share/public/components/tabs/link/time_type_section.test.tsx rename to src/platform/plugins/shared/share/public/components/common/time_type_selection/time_type_selection.test.tsx index 630a3863b8977..cc1913a9d4210 100644 --- a/src/platform/plugins/shared/share/public/components/tabs/link/time_type_section.test.tsx +++ b/src/platform/plugins/shared/share/public/components/common/time_type_selection/time_type_selection.test.tsx @@ -11,17 +11,17 @@ import type { ComponentProps } from 'react'; import React from 'react'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { render, screen } from '@testing-library/react'; -import { TimeTypeSection } from './time_type_section'; +import { TimeTypeSelection } from './time_type_selection'; -const renderComponent = (props: ComponentProps) => { +const renderComponent = (props: ComponentProps) => { render( - + ); }; -describe('TimeTypeSection', () => { +describe('TimeTypeSelection', () => { beforeEach(() => { jest.clearAllMocks(); }); diff --git a/src/platform/plugins/shared/share/public/components/tabs/link/time_type_section.tsx b/src/platform/plugins/shared/share/public/components/common/time_type_selection/time_type_selection.tsx similarity index 91% rename from src/platform/plugins/shared/share/public/components/tabs/link/time_type_section.tsx rename to src/platform/plugins/shared/share/public/components/common/time_type_selection/time_type_selection.tsx index 3b9e65735496a..fa832decbcb8c 100644 --- a/src/platform/plugins/shared/share/public/components/tabs/link/time_type_section.tsx +++ b/src/platform/plugins/shared/share/public/components/common/time_type_selection/time_type_selection.tsx @@ -69,7 +69,7 @@ const RelativeTimeText = ({ if (isToDate) { return ( {chunks}, @@ -112,7 +112,7 @@ const RelativeTimeText = ({ /> {roundingUnit && ( { return ( { ); }; -export const TimeTypeSection = ({ +export const TimeTypeSelection = ({ timeRange, onTimeTypeChange, isAbsoluteTimeByDefault, @@ -186,7 +186,7 @@ export const TimeTypeSection = ({ {!isAbsoluteTimeByDefault && ( <> , @@ -217,7 +217,7 @@ export const TimeTypeSection = ({ = ({ shareContext }) => { return ( @@ -75,6 +77,7 @@ export interface ManagedFlyoutProps { sharingData: { [key: string]: unknown; }; + shareableUrlLocatorParams?: ShareContext['shareableUrlLocatorParams']; } function LayoutOptionsSwitch({ usePrintLayout, printLayoutChange }: LayoutOptionsProps) { @@ -140,6 +143,7 @@ export function ManagedFlyout({ onSave, isSaving, sharingData, + shareableUrlLocatorParams, }: ManagedFlyoutProps) { const [usePrintLayout, setPrintLayout] = useState(false); const [isCreatingExport, setIsCreatingExport] = useState(false); @@ -153,22 +157,31 @@ export function ManagedFlyout({ return null; }, [exportIntegration.config, sharingData.totalHits]); + const timeRange = shareableUrlLocatorParams?.params?.timeRange; + const isAbsoluteTimeByDefault = isTimeRangeAbsoluteTime(timeRange); + const [isAbsoluteTime, setIsAbsoluteTime] = useState(isAbsoluteTimeByDefault); + const getReport = useCallback(async () => { try { setIsCreatingExport(true); await exportIntegration.config.generateAssetExport({ intl, optimizedForPrinting: usePrintLayout, + useAbsoluteTime: isAbsoluteTime, }); } finally { setIsCreatingExport(false); onCloseFlyout(); } - }, [exportIntegration.config, intl, onCloseFlyout, usePrintLayout]); + }, [exportIntegration.config, intl, onCloseFlyout, usePrintLayout, isAbsoluteTime]); const draftModeCallout = shareObjectTypeMeta.config?.[exportIntegration.id]?.draftModeCallOut; const draftModeCalloutContent = typeof draftModeCallout === 'object' ? draftModeCallout : {}; + const handleTimeTypeChange = useCallback((value: boolean) => { + setIsAbsoluteTime(value); + }, []); + return ( @@ -197,6 +210,13 @@ export function ManagedFlyout({ )} + + + {exportIntegration?.config.copyAssetURIConfig && publicAPIEnabled && ( @@ -228,6 +248,7 @@ export function ManagedFlyout({ {exportIntegration.config.copyAssetURIConfig.generateAssetURIValue({ intl, optimizedForPrinting: usePrintLayout, + useAbsoluteTime: isAbsoluteTime, })} @@ -296,6 +317,7 @@ function ExportMenuPopover({ intl }: ExportMenuProps) { objectTypeAlias, objectTypeMeta, sharingData, + shareableUrlLocatorParams, } = useShareTypeContext('integration', 'export'); const { shareMenuItems: exportDerivatives } = useShareTypeContext( 'integration', @@ -314,6 +336,7 @@ function ExportMenuPopover({ intl }: ExportMenuProps) { id: string; group: keyof typeof selectionOptions.current; }>(); + const selectedMenuItem = useMemo(() => { let result: ExportShareConfig | ExportShareDerivativesConfig | null = null; @@ -346,6 +369,7 @@ function ExportMenuPopover({ intl }: ExportMenuProps) { .generateAssetExport({ intl, optimizedForPrinting: false, + useAbsoluteTime: false, }) .finally(() => { onClose(); @@ -435,6 +459,7 @@ function ExportMenuPopover({ intl }: ExportMenuProps) { {isFlyoutVisible && ( ) : ( (selectedMenuItem as ExportShareDerivativesConfig)?.config.flyoutContent({ diff --git a/src/platform/plugins/shared/share/public/components/share_context_menu.tsx b/src/platform/plugins/shared/share/public/components/share_context_menu.tsx index f2763167ec78a..a4911e3d43ff3 100644 --- a/src/platform/plugins/shared/share/public/components/share_context_menu.tsx +++ b/src/platform/plugins/shared/share/public/components/share_context_menu.tsx @@ -22,7 +22,7 @@ import type { ShareMenuItemLegacy, ShareContextMenuPanelItem, UrlParamExtension, - ShareableUrlLocatorParams, + ShareableLocatorParams, } from '../types'; import type { AnonymousAccessServiceContract } from '../../common/anonymous_access'; import type { BrowserUrlService } from '../types'; @@ -36,7 +36,7 @@ export interface ShareContextMenuProps { shareableUrlForSavedObject?: string; shareableUrlLocatorParams?: { locator: LocatorPublic; - params: ShareableUrlLocatorParams; + params: ShareableLocatorParams; }; shareMenuItems: ShareMenuItemLegacy[]; sharingData: any; diff --git a/src/platform/plugins/shared/share/public/components/tabs/link/link_content.tsx b/src/platform/plugins/shared/share/public/components/tabs/link/link_content.tsx index f12c0ba3aca70..1b7a8f53f073b 100644 --- a/src/platform/plugins/shared/share/public/components/tabs/link/link_content.tsx +++ b/src/platform/plugins/shared/share/public/components/tabs/link/link_content.tsx @@ -20,7 +20,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import React, { useCallback, useState, useRef, useEffect } from 'react'; import { isTimeRangeAbsoluteTime } from '../../../lib/time_utils'; -import { TimeTypeSection } from './time_type_section'; +import { TimeTypeSelection } from '../../common/time_type_selection'; import { useShareContext, type IShareContext } from '../../context'; import type { LinkShareConfig, LinkShareUIConfig } from '../../../types'; import { DraftModeCallout } from '../../common/draft_mode_callout'; @@ -140,7 +140,7 @@ export const LinkContent = ({ return ( <> - ; - params: ShareableUrlLocatorParams; + params: ShareableLocatorParams; }; urlParamExtensions?: UrlParamExtension[]; anonymousAccess?: AnonymousAccessServiceContract; diff --git a/src/platform/plugins/shared/share/public/index.ts b/src/platform/plugins/shared/share/public/index.ts index fb46230487bc0..59444bf526093 100644 --- a/src/platform/plugins/shared/share/public/index.ts +++ b/src/platform/plugins/shared/share/public/index.ts @@ -30,7 +30,7 @@ export type { ExportShareConfig, ExportShareDerivatives, RegisterShareIntegrationArgs, - ShareableUrlLocatorParams, + ShareableLocatorParams, } from './types'; export type { RedirectOptions } from '../common/url_service'; diff --git a/src/platform/plugins/shared/share/public/services/share_menu_manager.tsx b/src/platform/plugins/shared/share/public/services/share_menu_manager.tsx index b95b92f612abc..f26c620243c6f 100644 --- a/src/platform/plugins/shared/share/public/services/share_menu_manager.tsx +++ b/src/platform/plugins/shared/share/public/services/share_menu_manager.tsx @@ -10,7 +10,7 @@ import React, { createRef } from 'react'; import ReactDOM from 'react-dom'; import { toMountPoint } from '@kbn/react-kibana-mount'; -import type { CoreStart } from '@kbn/core/public'; +import type { CoreStart, OverlayFlyoutOpenOptions } from '@kbn/core/public'; import type { RenderingService } from '@kbn/core-rendering-browser'; import type { InjectedIntl } from '@kbn/i18n-react'; import type { @@ -67,6 +67,7 @@ export class ShareMenuManager { core.rendering ); }, + /** * Returns a handler to trigger a specific export integration by ID. * For direct exports, executes immediately; otherwise opens a flyout. @@ -76,74 +77,77 @@ export class ShareMenuManager { exportId: string, intl: InjectedIntl ): Promise<(() => Promise) | null> => { - const onClose = () => { - this.onClose(); - options.onClose?.(); - }; - - const menuItems = await resolveShareItemsForShareContext({ - ...options, + return this.createExportHandler( + core, + options, + resolveShareItemsForShareContext, isServerless, - onClose, - }); - - const exportIntegration = menuItems.find( - (item) => - item.shareType === 'integration' && item.id === exportId && item.groupId === 'export' - ); + (menuItems, getFlyoutSession, onClose) => { + const exportIntegration = menuItems.find( + (item) => + item.shareType === 'integration' && + item.id === exportId && + item.groupId === 'export' + ); - if (!exportIntegration) { - return null; - } + if (!exportIntegration) { + return null; + } - const exportConfig = exportIntegration.config as ExportShareConfig['config']; - - // Direct export (no UI needed) - execute immediately - if ( - !exportConfig.copyAssetURIConfig && - !exportConfig.generateAssetComponent && - exportConfig.generateAssetExport - ) { - return async () => { - await exportConfig - .generateAssetExport({ intl, optimizedForPrinting: false }) - .finally(onClose); - }; - } + const exportConfig = exportIntegration.config as ExportShareConfig['config']; - // Export needs UI (flyout) - return async () => { - const flyoutSession = core.overlays.openFlyout( - toMountPoint( - { - flyoutSession.close(); - onClose(); - }} - publicAPIEnabled={!isServerless} - shareObjectType={options.objectType} - shareObjectTypeAlias={options.objectTypeAlias} - shareObjectTypeMeta={ - options.objectTypeMeta as ManagedFlyoutProps['shareObjectTypeMeta'] - } - onSave={options.onSave} - isSaving={false} - sharingData={options.sharingData} - />, - core - ), - { - 'data-test-subj': 'exportItemDetailsFlyout', - size: 's', - ownFocus: true, - container: null, // "global" flyout + // Direct export (no UI needed) - execute immediately + if ( + !exportConfig.copyAssetURIConfig && + !exportConfig.generateAssetComponent && + exportConfig.generateAssetExport + ) { + return async () => { + await exportConfig + .generateAssetExport({ + intl, + optimizedForPrinting: false, + useAbsoluteTime: false, + }) + .finally(onClose); + }; } - ); - }; + + const flyoutSession = getFlyoutSession(); + + return { + flyoutContent: ( + { + flyoutSession.close(); + onClose(); + }} + publicAPIEnabled={!isServerless} + shareObjectType={options.objectType} + shareObjectTypeAlias={options.objectTypeAlias} + shareObjectTypeMeta={ + options.objectTypeMeta as ManagedFlyoutProps['shareObjectTypeMeta'] + } + onSave={options.onSave} + isSaving={false} + sharingData={options.sharingData} + shareableUrlLocatorParams={options.shareableUrlLocatorParams} + /> + ), + overlayOptions: { + 'data-test-subj': 'exportItemDetailsFlyout', + size: 's', + ownFocus: true, + container: null, // "global" flyout, + }, + }; + } + ); }, + /** * Returns a handler to trigger an export derivative by ID, opening its custom flyout. */ @@ -151,79 +155,125 @@ export class ShareMenuManager { options: Omit, derivativeId: string ): Promise<(() => Promise) | null> => { - const onClose = () => { - this.onClose(); - options.onClose?.(); - }; - - const menuItems = await resolveShareItemsForShareContext({ - ...options, + return this.createExportHandler( + core, + options, + resolveShareItemsForShareContext, isServerless, - onClose, - }); + (menuItems, getFlyoutSession, onClose) => { + const derivative = menuItems.find( + (item) => + item.shareType === 'integration' && + item.groupId === 'exportDerivatives' && + item.id === derivativeId + ); + + if (!derivative) { + return null; + } + + const exportItems = menuItems.filter( + (item) => item.shareType === 'integration' && item.groupId === 'export' + ) as ExportShareConfig[]; + + const derivativeConfig = (derivative as ExportShareDerivativesConfig).config; + + if (!derivativeConfig.shouldRender({ availableExportItems: exportItems })) { + return null; + } + + const flyoutRef = createRef(); - const derivative = menuItems.find( - (item) => - item.shareType === 'integration' && - item.groupId === 'exportDerivatives' && - item.id === derivativeId + const flyoutSession = getFlyoutSession(); + + return { + flyoutContent: derivativeConfig.flyoutContent({ + closeFlyout: () => { + flyoutSession.close(); + onClose(); + }, + flyoutRef, + }), + overlayOptions: { + 'data-test-subj': `exportDerivativeFlyout-${derivativeId}`, + ownFocus: true, + container: null, // "global" flyout, + ...(derivativeConfig.flyoutSizing || {}), + }, + }; + } ); + }, + }; + } - if (!derivative) { - return null; + /** + * Method for handling export operations flexibly. + */ + private async createExportHandler( + core: CoreStart, + options: Omit, + resolveShareObjectTypeItems: ShareRegistry['resolveShareItemsForShareContext'], + isServerless: boolean, + cb: ( + objectTypeMenuItems: ShareConfigs[], + getFlyoutSession: () => ReturnType, + onClose: () => void + ) => + | null + | (() => Promise) + | { + flyoutContent: React.ReactNode | null; + overlayOptions?: OverlayFlyoutOpenOptions; } + ) { + const onClose = () => { + this.onClose(); + options.onClose?.(); + }; - const exportItems = menuItems.filter( - (item) => item.shareType === 'integration' && item.groupId === 'export' - ) as ExportShareConfig[]; + const menuItems = await resolveShareObjectTypeItems({ + ...options, + isServerless, + onClose, + }); - const derivativeConfig = (derivative as ExportShareDerivativesConfig).config; + const shareContext: IShareContext = { + objectId: options.objectId, + objectType: options.objectType, + objectTypeAlias: options.objectTypeAlias, + objectTypeMeta: options.objectTypeMeta, + publicAPIEnabled: !isServerless, + allowShortUrl: options.allowShortUrl, + sharingData: options.sharingData, + shareableUrl: options.shareableUrl, + shareableUrlLocatorParams: options.shareableUrlLocatorParams, + isDirty: options.isDirty, + shareMenuItems: menuItems, + onClose, + onSave: options.onSave, + }; - if (!derivativeConfig.shouldRender({ availableExportItems: exportItems })) { - return null; - } + let flyoutSession: ReturnType; - return async () => { - const flyoutRef = createRef(); + const cbResult = await cb(menuItems, () => flyoutSession, onClose); - const shareContext: IShareContext = { - objectId: options.objectId, - objectType: options.objectType, - objectTypeAlias: options.objectTypeAlias, - objectTypeMeta: options.objectTypeMeta, - publicAPIEnabled: !isServerless, - allowShortUrl: options.allowShortUrl, - sharingData: options.sharingData, - shareableUrl: options.shareableUrl, - shareableUrlLocatorParams: options.shareableUrlLocatorParams, - isDirty: options.isDirty, - shareMenuItems: menuItems, - onClose, - onSave: options.onSave, - }; - - const flyoutSession = core.overlays.openFlyout( - toMountPoint( - - {derivativeConfig.flyoutContent({ - closeFlyout: () => { - flyoutSession.close(); - onClose(); - }, - flyoutRef, - })} - , - core - ), - { - 'data-test-subj': `exportDerivativeFlyout-${derivativeId}`, - ownFocus: true, - container: null, // "global" flyout - ...(derivativeConfig.flyoutSizing || {}), - } - ); - }; - }, + if (!cbResult) { + return null; + } + + if (typeof cbResult === 'function') { + return cbResult; + } + + return async () => { + flyoutSession = core.overlays.openFlyout( + toMountPoint( + {cbResult.flyoutContent}, + core + ), + cbResult.overlayOptions + ); }; } diff --git a/src/platform/plugins/shared/share/public/types.ts b/src/platform/plugins/shared/share/public/types.ts index 5edf7d4566564..6fc78ba64b564 100644 --- a/src/platform/plugins/shared/share/public/types.ts +++ b/src/platform/plugins/shared/share/public/types.ts @@ -296,7 +296,7 @@ export type BrowserUrlService = UrlService< BrowserShortUrlClient >; -export type ShareableUrlLocatorParams = { +export type ShareableLocatorParams = { timeRange: TimeRange | undefined; } & Record; @@ -347,7 +347,7 @@ export interface ShareContext { shareableUrlForSavedObject?: string; shareableUrlLocatorParams?: { locator: LocatorPublic; - params: ShareableUrlLocatorParams; + params: ShareableLocatorParams; }; sharingData: { [key: string]: unknown }; isDirty: boolean; @@ -384,6 +384,7 @@ export interface ShareMenuItemLegacy extends ShareMenuItemBase { export interface ExportGenerationOpts { optimizedForPrinting?: boolean; intl: InjectedIntl; + useAbsoluteTime: boolean; } interface UrlParamExtensionProps { From 0069e873ddcbf299cd5869bdc50a466fb90ec048 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Wed, 25 Feb 2026 20:21:41 +0100 Subject: [PATCH 2/9] share implementation adjustments to support receiving absolute time range as is --- packages/kbn-optimizer/limits.yml | 2 +- .../private/kbn-reporting/common/url.ts | 2 +- .../private/kbn-reporting/public/moon.yml | 2 + .../integrations/csv/csv_export_config.tsx | 104 ++++++++---- .../public/share/share_context_menu/index.ts | 3 +- .../kbn-reporting/public/tsconfig.json | 2 + .../private/kbn-reporting/public/types.ts | 25 ++- src/platform/plugins/shared/discover/moon.yml | 1 + .../top_nav/app_menu_actions/get_share.tsx | 22 ++- .../locator/filters_from_locator.test.ts | 156 ++++++++++++++++++ .../server/locator/filters_from_locator.ts | 12 +- .../server/locator/resolve_time_field_name.ts | 43 +++++ .../time_field_name_from_locator.test.ts | 37 +++++ .../locator/time_field_name_from_locator.ts | 3 +- .../plugins/shared/discover/tsconfig.json | 1 + .../common/time_type_selection/index.ts | 10 -- .../export_integrations.test.tsx | 6 +- .../export_integrations.tsx | 33 +--- .../components/export_integrations/index.ts | 6 +- .../public/components/share_tabs.test.tsx | 2 +- .../tabs/embed/embed_content.test.tsx | 2 +- .../tabs/link/link_content.test.tsx | 2 +- .../components/tabs/link/link_content.tsx | 4 +- .../link/time_type_section.test.tsx} | 8 +- .../link/time_type_section.tsx} | 18 +- .../plugins/shared/share/public/index.ts | 2 + .../public/services/share_menu_manager.tsx | 21 ++- .../plugins/shared/share/public/types.ts | 45 ++++- 28 files changed, 449 insertions(+), 125 deletions(-) create mode 100644 src/platform/plugins/shared/discover/server/locator/filters_from_locator.test.ts create mode 100644 src/platform/plugins/shared/discover/server/locator/resolve_time_field_name.ts delete mode 100644 src/platform/plugins/shared/share/public/components/common/time_type_selection/index.ts rename src/platform/plugins/shared/share/public/components/{common/time_type_selection/time_type_selection.test.tsx => tabs/link/time_type_section.test.tsx} (96%) rename src/platform/plugins/shared/share/public/components/{common/time_type_selection/time_type_selection.tsx => tabs/link/time_type_section.tsx} (91%) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 69f1a56ef7cad..ca95560c8e96e 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -170,7 +170,7 @@ pageLoadAssetSize: serverlessSearch: 26287 serverlessWorkplaceAI: 5078 sessionView: 47912 - share: 58677 + share: 64618 slo: 40437 snapshotRestore: 22068 spaces: 28871 diff --git a/src/platform/packages/private/kbn-reporting/common/url.ts b/src/platform/packages/private/kbn-reporting/common/url.ts index dc6bfd257b511..a43affac71812 100644 --- a/src/platform/packages/private/kbn-reporting/common/url.ts +++ b/src/platform/packages/private/kbn-reporting/common/url.ts @@ -22,7 +22,7 @@ export interface LocatorParams

{ + return locatorParams.map((lp) => { + if (!absoluteTimeRange) { + return lp; + } + + return { + ...lp, + params: { + ...lp.params, + timeRange: absoluteTimeRange, + }, + }; + }); +}; + export const getCsvReportParams: ReportParamsGetter< - ReportParamsGetterOptions & { forShareUrl?: boolean }, + ReportParamsGetterOptions & { + forShareUrl?: boolean; + useAbsoluteTime?: boolean; + }, CsvSearchModeParams -> = ({ sharingData, forShareUrl = false }) => { - const getSearchSource = sharingData.getSearchSource as ({ - addGlobalTimeFilter, - absoluteTime, - }: { - addGlobalTimeFilter?: boolean; - absoluteTime?: boolean; - }) => SerializedSearchSourceFields; +> = ({ sharingData, forShareUrl = false, useAbsoluteTime = true }) => { + const getSearchSource = sharingData.getSearchSource; if (sharingData.isTextBased) { - // csv v2 uses locator params + const locatorParams = sharingData.locatorParams; + return { isEsqlMode: true, - locatorParams: sharingData.locatorParams as LocatorParams[], + locatorParams: useAbsoluteTime + ? toAbsoluteTimeRange(locatorParams, sharingData.absoluteTimeRange) + : locatorParams, }; } // csv v1 uses search source and columns return { isEsqlMode: false, - columns: sharingData.columns as string[] | undefined, + columns: sharingData.columns, searchSource: getSearchSource({ addGlobalTimeFilter: true, absoluteTime: !forShareUrl, @@ -61,15 +84,30 @@ export const getShareMenuItems = ({ objectType, sharingData, - }: ShareContext): ReturnType extends Promise ? R : never => { - const getSearchModeParams = (forShareUrl?: boolean): CsvSearchModeParams => - getCsvReportParams({ sharingData, forShareUrl }); + shareableUrlLocatorParams, + }: ShareContext): ReturnType extends Promise< + infer R + > + ? R + : never => { + const getSearchModeParams = ({ + forShareUrl, + useAbsoluteTime, + }: { + forShareUrl?: boolean; + useAbsoluteTime?: boolean; + } = {}): CsvSearchModeParams => + getCsvReportParams({ + sharingData, + forShareUrl, + useAbsoluteTime, + }); - const generateReportingJobCSV = ({ intl }: { intl: InjectedIntl }) => { + const generateReportingJobCSV = ({ intl, useAbsoluteTime }: ExportGenerationOpts) => { const { reportType, decoratedJobParams } = getSearchCsvJobParams({ apiClient, - searchModeParams: getSearchModeParams(false), - title: sharingData.title as string, + searchModeParams: getSearchModeParams({ useAbsoluteTime }), + title: sharingData.title, }); return firstValueFrom(startServices$).then(([startServices]) => { @@ -128,21 +166,31 @@ export const getShareMenuItems = defaultMessage: 'Export', }); - const { reportType, decoratedJobParams } = getSearchCsvJobParams({ + const { reportType } = getSearchCsvJobParams({ apiClient, - searchModeParams: getSearchModeParams(true), - title: sharingData.title as string, + searchModeParams: getSearchModeParams({ forShareUrl: true }), + title: sharingData.title, }); - const relativePath = apiClient.getReportingPublicJobPath(reportType, decoratedJobParams); - - const absoluteUrl = new URL(relativePath, window.location.href).toString(); + const getAbsoluteUrl = () => { + const { reportType: _reportType, decoratedJobParams } = getSearchCsvJobParams({ + apiClient, + searchModeParams: getSearchModeParams({ + forShareUrl: true, + useAbsoluteTime: false, + }), + title: sharingData.title, + }); + const relativePath = apiClient.getReportingPublicJobPath(_reportType, decoratedJobParams); + return new URL(relativePath, window.location.href).toString(); + }; return { name: panelTitle, exportType: reportType, label: 'CSV', icon: 'table', + supportsAbsoluteTime: true, generateAssetExport: generateReportingJobCSV, helpText: ( absoluteUrl, + generateAssetURIValue: getAbsoluteUrl, }, renderTotalHitsSizeWarning: (totalHits: number = 0): React.ReactNode => { const maxRows = csvConfig?.maxRows || 0; diff --git a/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/index.ts b/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/index.ts index ce37f02a0a7cd..8afbf4c77f330 100644 --- a/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/index.ts +++ b/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/index.ts @@ -11,6 +11,7 @@ import type * as Rx from 'rxjs'; import type { ApplicationStart, CoreStart } from '@kbn/core/public'; import type { ILicense } from '@kbn/licensing-types'; +import type { SerializableRecord } from '@kbn/utility-types'; import type { ReportingAPIClient } from '../../reporting_api_client'; import type { ClientConfigType } from '../../types'; @@ -46,7 +47,7 @@ export interface ReportingSharingData { reportingDisabled?: boolean; locatorParams: { id: string; - params: unknown; + params: SerializableRecord; }; } diff --git a/src/platform/packages/private/kbn-reporting/public/tsconfig.json b/src/platform/packages/private/kbn-reporting/public/tsconfig.json index fbdc5551f46e5..44c89ad7565ad 100644 --- a/src/platform/packages/private/kbn-reporting/public/tsconfig.json +++ b/src/platform/packages/private/kbn-reporting/public/tsconfig.json @@ -29,5 +29,7 @@ "@kbn/ui-actions-plugin", "@kbn/actions-plugin", "@kbn/licensing-types", + "@kbn/es-query", + "@kbn/utility-types" ] } diff --git a/src/platform/packages/private/kbn-reporting/public/types.ts b/src/platform/packages/private/kbn-reporting/public/types.ts index cdccd54ab62d9..de4aa6c00e69a 100644 --- a/src/platform/packages/private/kbn-reporting/public/types.ts +++ b/src/platform/packages/private/kbn-reporting/public/types.ts @@ -11,8 +11,12 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { HomePublicPluginStart } from '@kbn/home-plugin/public'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { ManagementStart } from '@kbn/management-plugin/public'; -import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { SharePluginStart, SharingData } from '@kbn/share-plugin/public'; import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; +import type { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import type { LocatorParams } from '@kbn/reporting-common/types'; +import type { TimeRange } from '@kbn/es-query'; +import type { SerializableRecord } from '@kbn/utility-types'; export interface ReportingPublicPluginStartDependencies { home: HomePublicPluginStart; @@ -45,9 +49,24 @@ export interface ClientConfigType { statefulSettings: { enabled: boolean }; } -export interface ReportParamsGetterOptions { +export type ReportingCSVSharingDataLocatorParams = Array< + LocatorParams +>; + +export interface ReportingCSVSharingData extends Exclude { + locatorParams: ReportingCSVSharingDataLocatorParams; + isTextBased: boolean; + getSearchSource: (args: { + addGlobalTimeFilter?: boolean; + absoluteTime?: boolean; + }) => SerializedSearchSourceFields; + columns: string[] | undefined; + absoluteTimeRange: TimeRange | undefined; +} + +export interface ReportParamsGetterOptions { objectType?: string; - sharingData: any; + sharingData: S; } export type ReportParamsGetter< diff --git a/src/platform/plugins/shared/discover/moon.yml b/src/platform/plugins/shared/discover/moon.yml index 505c610ae9090..e5a4fcf4eafb5 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -148,6 +148,7 @@ dependsOn: - '@kbn/as-code-filters-constants' - '@kbn/core-analytics-browser-mocks' - '@kbn/core-http-server-mocks' + - '@kbn/reporting-public' tags: - plugin - prod diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx index 9f358cd9ffde5..f09ee49763487 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx @@ -11,12 +11,12 @@ import { AppMenuActionId } from '@kbn/discover-utils'; import { omit } from 'lodash'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import { i18n } from '@kbn/i18n'; -import type { TimeRange } from '@kbn/es-query'; import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; import type { AppMenuItemType, AppMenuPopoverItem } from '@kbn/core-chrome-app-menu-components'; import type { ShowShareMenuOptions } from '@kbn/share-plugin/public'; -import type { ShareActionIntents } from '@kbn/share-plugin/public/types'; +import type { ShareActionIntents, SharingData } from '@kbn/share-plugin/public/types'; import type { IntlShape } from '@kbn/i18n-react'; +import type { ReportingCSVSharingData } from '@kbn/reporting-public/types'; import type { DataTotalHitsMsg } from '../../../state_management/discover_data_state_container'; import { getSharingData, showPublicUrlSwitch } from '../../../../../utils/get_sharing_data'; import { createSearchSource } from '../../../state_management/utils/create_search_source'; @@ -34,6 +34,11 @@ interface BuildShareOptionsParams { hasUnsavedChanges: boolean; } +/** + * Specifies an explicit type for the sharing data of the Discover app. + */ +type DiscoverSharingData = SharingData & ReportingCSVSharingData; + /** * Builds share options for both share modal and export integrations */ @@ -44,7 +49,12 @@ const buildShareOptions = async ({ persistedDiscoverSession, totalHitsState, hasUnsavedChanges, -}: BuildShareOptionsParams): Promise> => { +}: BuildShareOptionsParams): Promise< + Omit< + ShowShareMenuOptions, + 'anchorElement' | 'asExport' + > +> => { const { dataView, isEsqlMode } = discoverParams; const searchSource = createSearchSource({ @@ -64,18 +74,19 @@ const buildShareOptions = async ({ const { locator } = services; const { timefilter } = services.data.query.timefilter; const timeRange = timefilter.getTime(); + const absoluteTimeRange = timefilter.getAbsoluteTime(); const refreshInterval = timefilter.getRefreshInterval(); const filters = services.filterManager.getFilters(); // Share -> Get links -> Snapshot - const params: DiscoverAppLocatorParams & { timeRange: TimeRange | undefined } = { + const params: DiscoverSharingData['locatorParams'][number]['params'] = { ...omit(currentTab.appState, 'dataSource'), ...(persistedDiscoverSession?.id ? { savedSearchId: persistedDiscoverSession.id } : {}), ...(dataView?.isPersisted() ? { dataViewId: dataView?.id } : { dataViewSpec: dataView?.toMinimalSpec() }), filters, - timeRange: timeRange ?? undefined, + timeRange, refreshInterval, }; @@ -149,6 +160,7 @@ const buildShareOptions = async ({ defaultMessage: 'Untitled Discover session', }), totalHits: totalHitsState.result || 0, + absoluteTimeRange, }, isDirty: !persistedDiscoverSession?.id || hasUnsavedChanges, }; diff --git a/src/platform/plugins/shared/discover/server/locator/filters_from_locator.test.ts b/src/platform/plugins/shared/discover/server/locator/filters_from_locator.test.ts new file mode 100644 index 0000000000000..2a19e3715dfd9 --- /dev/null +++ b/src/platform/plugins/shared/discover/server/locator/filters_from_locator.test.ts @@ -0,0 +1,156 @@ +/* + * 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 { IUiSettingsClient, SavedObjectsClientContract } from '@kbn/core/server'; +import { coreMock, httpServerMock } from '@kbn/core/server/mocks'; +import type { ISearchStartSearchSource } from '@kbn/data-plugin/common'; +import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; +import type { LocatorServicesDeps as Services } from '.'; +import { filtersFromLocatorFactory } from './filters_from_locator'; + +const coreStart = coreMock.createStart(); +let uiSettingsClient: IUiSettingsClient; +let soClient: SavedObjectsClientContract; +let searchSourceStart: ISearchStartSearchSource; +let mockServices: Services; + +beforeAll(async () => { + const dataStartMock = dataPluginMock.createStartContract(); + const request = httpServerMock.createKibanaRequest(); + soClient = coreStart.savedObjects.getScopedClient(request); + uiSettingsClient = coreMock.createStart().uiSettings.asScopedToClient(soClient); + searchSourceStart = await dataStartMock.search.searchSource.asScoped(request); + + mockServices = { + searchSourceStart, + savedObjects: soClient, + uiSettings: uiSettingsClient, + }; +}); + +test('creates a time range filter from dataViewSpec.timeFieldName', async () => { + const params = { + timeRange: { from: '2024-01-01T00:00:00.000Z', to: '2024-01-02T00:00:00.000Z' }, + dataViewSpec: { timeFieldName: '@timestamp' }, + }; + + const filtersFromLocator = filtersFromLocatorFactory(mockServices); + const filters = await filtersFromLocator(params); + + expect(filters).toEqual([ + { + meta: {}, + query: { + range: { + '@timestamp': { + format: 'strict_date_optional_time', + gte: '2024-01-01T00:00:00.000Z', + lte: '2024-01-02T00:00:00.000Z', + }, + }, + }, + }, + ]); +}); + +test('creates a time range filter by resolving dataViewId when dataViewSpec is absent', async () => { + soClient.get = jest.fn().mockResolvedValue({ + id: 'test-data-view-id', + type: 'index-pattern', + attributes: { timeFieldName: '@timestamp' }, + references: [], + }); + + const params = { + timeRange: { from: 'now-15m', to: 'now' }, + dataViewId: 'test-data-view-id', + }; + + const filtersFromLocator = filtersFromLocatorFactory(mockServices); + const filters = await filtersFromLocator(params); + + expect(filters).toEqual([ + { + meta: {}, + query: { + range: { + '@timestamp': { + format: 'strict_date_optional_time', + gte: 'now-15m', + lte: 'now', + }, + }, + }, + }, + ]); + expect(soClient.get).toHaveBeenCalledWith('index-pattern', 'test-data-view-id'); +}); + +test('does not create a time range filter when timeRange is absent', async () => { + const params = { + dataViewSpec: { timeFieldName: '@timestamp' }, + }; + + const filtersFromLocator = filtersFromLocatorFactory(mockServices); + const filters = await filtersFromLocator(params); + + expect(filters).toEqual([]); +}); + +test('does not create a time range filter when timeFieldName cannot be resolved', async () => { + const params = { + timeRange: { from: 'now-15m', to: 'now' }, + dataViewSpec: {}, + }; + + const filtersFromLocator = filtersFromLocatorFactory(mockServices); + const filters = await filtersFromLocator(params); + + expect(filters).toEqual([]); +}); + +test('includes user-provided filters alongside the time range filter', async () => { + const userFilter = { + meta: { alias: 'test' }, + query: { match: { status: 'active' } }, + }; + + const params = { + timeRange: { from: '2024-01-01T00:00:00.000Z', to: '2024-01-02T00:00:00.000Z' }, + dataViewSpec: { timeFieldName: '@timestamp' }, + filters: [userFilter], + }; + + const filtersFromLocator = filtersFromLocatorFactory(mockServices); + const filters = await filtersFromLocator(params); + + expect(filters).toHaveLength(2); + expect(filters[0]).toEqual( + expect.objectContaining({ + query: expect.objectContaining({ + range: expect.objectContaining({ '@timestamp': expect.anything() }), + }), + }) + ); + expect(filters[1]).toBe(userFilter); +}); + +test('gracefully handles dataViewId lookup failure and skips time range filter', async () => { + soClient.get = jest.fn().mockRejectedValue(new Error('Not found')); + + const params = { + timeRange: { from: 'now-15m', to: 'now' }, + dataViewId: 'nonexistent-id', + }; + + const filtersFromLocator = filtersFromLocatorFactory(mockServices); + const filters = await filtersFromLocator(params); + + expect(filters).toEqual([]); +}); diff --git a/src/platform/plugins/shared/discover/server/locator/filters_from_locator.ts b/src/platform/plugins/shared/discover/server/locator/filters_from_locator.ts index f37a60029d6de..72b2f4b96a6c5 100644 --- a/src/platform/plugins/shared/discover/server/locator/filters_from_locator.ts +++ b/src/platform/plugins/shared/discover/server/locator/filters_from_locator.ts @@ -10,6 +10,7 @@ import type { Filter } from '@kbn/es-query'; import type { LocatorServicesDeps } from '.'; import type { DiscoverAppLocatorParams } from '../../common'; +import { resolveTimeFieldName } from './resolve_time_field_name'; /** * @internal @@ -21,19 +22,18 @@ export const filtersFromLocatorFactory = (services: LocatorServicesDeps) => { const filtersFromLocator = async (params: DiscoverAppLocatorParams): Promise => { const filters: Filter[] = []; - if (params.timeRange && params.dataViewSpec?.timeFieldName) { - const timeRange = params.timeRange; - const timeFieldName = params.dataViewSpec.timeFieldName; + if (params.timeRange) { + const timeFieldName = await resolveTimeFieldName(params, services); - if (timeRange) { + if (timeFieldName) { filters.push({ meta: {}, query: { range: { [timeFieldName]: { format: 'strict_date_optional_time', - gte: timeRange.from, - lte: timeRange.to, + gte: params.timeRange.from, + lte: params.timeRange.to, }, }, }, diff --git a/src/platform/plugins/shared/discover/server/locator/resolve_time_field_name.ts b/src/platform/plugins/shared/discover/server/locator/resolve_time_field_name.ts new file mode 100644 index 0000000000000..7ab16a9a2e5bd --- /dev/null +++ b/src/platform/plugins/shared/discover/server/locator/resolve_time_field_name.ts @@ -0,0 +1,43 @@ +/* + * 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 { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common'; +import type { LocatorServicesDeps } from '.'; +import type { DiscoverAppLocatorParams } from '../../common'; + +/** + * Resolves the time field name from locator params, falling back to loading + * the data view from saved objects when only a dataViewId is provided. + * + * @internal + */ +export const resolveTimeFieldName = async ( + params: DiscoverAppLocatorParams, + services: LocatorServicesDeps +): Promise => { + if (params.dataViewSpec?.timeFieldName) { + return params.dataViewSpec.timeFieldName; + } + + if (params.dataViewId) { + try { + const dataViewSavedObject = await services.savedObjects.get( + DATA_VIEW_SAVED_OBJECT_TYPE, + params.dataViewId + ); + return (dataViewSavedObject.attributes as Record).timeFieldName as + | string + | undefined; + } catch { + return undefined; + } + } + + return undefined; +}; diff --git a/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.test.ts b/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.test.ts index 181fa9d29e00e..0696b42d9154d 100644 --- a/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.test.ts +++ b/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.test.ts @@ -47,3 +47,40 @@ test(`returns undefined if there is no timeFieldName in DiscoverAppLocatorParams const timeField = await timeFieldNameFromLocatorFn(params); expect(timeField).toBeUndefined(); }); + +test(`resolves timeFieldName from dataViewId when dataViewSpec is not provided`, async () => { + soClient.get = jest.fn().mockResolvedValue({ + id: 'test-data-view-id', + type: 'index-pattern', + attributes: { timeFieldName: '@timestamp' }, + references: [], + }); + + const params = { dataViewId: 'test-data-view-id' }; + const timeFieldNameFromLocatorFn = timeFieldNameFromLocatorFactory(mockServices); + const timeField = await timeFieldNameFromLocatorFn(params); + expect(timeField).toBe('@timestamp'); + expect(soClient.get).toHaveBeenCalledWith('index-pattern', 'test-data-view-id'); +}); + +test(`returns undefined when dataViewId lookup fails`, async () => { + soClient.get = jest.fn().mockRejectedValue(new Error('Not found')); + + const params = { dataViewId: 'nonexistent-id' }; + const timeFieldNameFromLocatorFn = timeFieldNameFromLocatorFactory(mockServices); + const timeField = await timeFieldNameFromLocatorFn(params); + expect(timeField).toBeUndefined(); +}); + +test(`prefers dataViewSpec.timeFieldName over dataViewId lookup`, async () => { + soClient.get = jest.fn(); + + const params = { + dataViewSpec: { timeFieldName: 'event.timestamp' }, + dataViewId: 'test-data-view-id', + }; + const timeFieldNameFromLocatorFn = timeFieldNameFromLocatorFactory(mockServices); + const timeField = await timeFieldNameFromLocatorFn(params); + expect(timeField).toBe('event.timestamp'); + expect(soClient.get).not.toHaveBeenCalled(); +}); diff --git a/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.ts b/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.ts index 336999eeebe40..bbe9a3b81cb4f 100644 --- a/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.ts +++ b/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.ts @@ -9,6 +9,7 @@ import type { LocatorServicesDeps } from '.'; import type { DiscoverAppLocatorParams } from '../../common'; +import { resolveTimeFieldName } from './resolve_time_field_name'; /** * @internal @@ -20,7 +21,7 @@ export const timeFieldNameFromLocatorFactory = (services: LocatorServicesDeps) = const timeFieldNameFromLocator = async ( params: DiscoverAppLocatorParams ): Promise => { - return params.dataViewSpec?.timeFieldName; + return resolveTimeFieldName(params, services); }; return timeFieldNameFromLocator; diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index 549b91456d0c3..b42f36cf511a3 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -143,6 +143,7 @@ "@kbn/as-code-filters-constants", "@kbn/core-analytics-browser-mocks", "@kbn/core-http-server-mocks", + "@kbn/reporting-public", ], "exclude": ["target/**/*"] } diff --git a/src/platform/plugins/shared/share/public/components/common/time_type_selection/index.ts b/src/platform/plugins/shared/share/public/components/common/time_type_selection/index.ts deleted file mode 100644 index e51ea3027a097..0000000000000 --- a/src/platform/plugins/shared/share/public/components/common/time_type_selection/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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". - */ - -export { TimeTypeSelection } from './time_type_selection'; diff --git a/src/platform/plugins/shared/share/public/components/export_integrations/export_integrations.test.tsx b/src/platform/plugins/shared/share/public/components/export_integrations/export_integrations.test.tsx index 75f5b9720d351..3d75f609118b1 100644 --- a/src/platform/plugins/shared/share/public/components/export_integrations/export_integrations.test.tsx +++ b/src/platform/plugins/shared/share/public/components/export_integrations/export_integrations.test.tsx @@ -13,7 +13,7 @@ import { userEvent } from '@testing-library/user-event'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; import { EuiFlyout, EuiButton } from '@elastic/eui'; -import { ExportMenu, ManagedFlyout } from './export_integrations'; +import { ExportMenu, ManagedExportFlyout } from './export_integrations'; import type { IShareContext } from '../context'; import type { ExportShareConfig, ShareConfigs } from '../../types'; @@ -48,7 +48,7 @@ const mockShareContext: IShareContext = { }, }, objectType: 'type', - sharingData: { title: 'title', url: 'url' }, + sharingData: { title: 'title', url: 'url', locatorParams: { id: 'test', params: {} } }, isDirty: false, onClose: jest.fn(), }; @@ -201,7 +201,7 @@ describe('Export Integrations', () => { if (isFlyoutVisible) { flyout = ( setIsFlyoutVisible(false)} aria-label="Export"> - = ({ shareContext }) => { return ( @@ -61,7 +59,7 @@ interface LayoutOptionsProps { printLayoutChange: (evt: EuiSwitchEvent) => void; } -export interface ManagedFlyoutProps { +export interface ManagedExportFlyoutProps { exportIntegration: ExportShareConfig; intl: InjectedIntl; isDirty: boolean; @@ -131,7 +129,7 @@ function LayoutOptionsSwitch({ usePrintLayout, printLayoutChange }: LayoutOption ); } -export function ManagedFlyout({ +export function ManagedExportFlyout({ exportIntegration, intl, isDirty, @@ -143,8 +141,7 @@ export function ManagedFlyout({ onSave, isSaving, sharingData, - shareableUrlLocatorParams, -}: ManagedFlyoutProps) { +}: ManagedExportFlyoutProps) { const [usePrintLayout, setPrintLayout] = useState(false); const [isCreatingExport, setIsCreatingExport] = useState(false); @@ -157,31 +154,22 @@ export function ManagedFlyout({ return null; }, [exportIntegration.config, sharingData.totalHits]); - const timeRange = shareableUrlLocatorParams?.params?.timeRange; - const isAbsoluteTimeByDefault = isTimeRangeAbsoluteTime(timeRange); - const [isAbsoluteTime, setIsAbsoluteTime] = useState(isAbsoluteTimeByDefault); - const getReport = useCallback(async () => { try { setIsCreatingExport(true); await exportIntegration.config.generateAssetExport({ intl, optimizedForPrinting: usePrintLayout, - useAbsoluteTime: isAbsoluteTime, }); } finally { setIsCreatingExport(false); onCloseFlyout(); } - }, [exportIntegration.config, intl, onCloseFlyout, usePrintLayout, isAbsoluteTime]); + }, [exportIntegration.config, intl, onCloseFlyout, usePrintLayout]); const draftModeCallout = shareObjectTypeMeta.config?.[exportIntegration.id]?.draftModeCallOut; const draftModeCalloutContent = typeof draftModeCallout === 'object' ? draftModeCallout : {}; - const handleTimeTypeChange = useCallback((value: boolean) => { - setIsAbsoluteTime(value); - }, []); - return ( @@ -210,13 +198,6 @@ export function ManagedFlyout({ )} - - - {exportIntegration?.config.copyAssetURIConfig && publicAPIEnabled && ( @@ -248,7 +229,7 @@ export function ManagedFlyout({ {exportIntegration.config.copyAssetURIConfig.generateAssetURIValue({ intl, optimizedForPrinting: usePrintLayout, - useAbsoluteTime: isAbsoluteTime, + useAbsoluteTime: false, })} @@ -369,7 +350,6 @@ function ExportMenuPopover({ intl }: ExportMenuProps) { .generateAssetExport({ intl, optimizedForPrinting: false, - useAbsoluteTime: false, }) .finally(() => { onClose(); @@ -459,7 +439,6 @@ function ExportMenuPopover({ intl }: ExportMenuProps) { {isFlyoutVisible && ( {selectedMenuItemMeta!.group === 'export' ? ( - - ) => { +const renderComponent = (props: ComponentProps) => { render( - + ); }; -describe('TimeTypeSelection', () => { +describe('TimeTypeSection', () => { beforeEach(() => { jest.clearAllMocks(); }); diff --git a/src/platform/plugins/shared/share/public/components/common/time_type_selection/time_type_selection.tsx b/src/platform/plugins/shared/share/public/components/tabs/link/time_type_section.tsx similarity index 91% rename from src/platform/plugins/shared/share/public/components/common/time_type_selection/time_type_selection.tsx rename to src/platform/plugins/shared/share/public/components/tabs/link/time_type_section.tsx index fa832decbcb8c..3b9e65735496a 100644 --- a/src/platform/plugins/shared/share/public/components/common/time_type_selection/time_type_selection.tsx +++ b/src/platform/plugins/shared/share/public/components/tabs/link/time_type_section.tsx @@ -69,7 +69,7 @@ const RelativeTimeText = ({ if (isToDate) { return ( {chunks}, @@ -112,7 +112,7 @@ const RelativeTimeText = ({ /> {roundingUnit && ( { return ( { ); }; -export const TimeTypeSelection = ({ +export const TimeTypeSection = ({ timeRange, onTimeTypeChange, isAbsoluteTimeByDefault, @@ -186,7 +186,7 @@ export const TimeTypeSelection = ({ {!isAbsoluteTimeByDefault && ( <> , @@ -217,7 +217,7 @@ export const TimeTypeSelection = ({ { - flyoutSession.close(); + getFlyoutSession().close(); onClose(); }} publicAPIEnabled={!isServerless} shareObjectType={options.objectType} shareObjectTypeAlias={options.objectTypeAlias} shareObjectTypeMeta={ - options.objectTypeMeta as ManagedFlyoutProps['shareObjectTypeMeta'] + options.objectTypeMeta as ManagedExportFlyoutProps['shareObjectTypeMeta'] } onSave={options.onSave} isSaving={false} @@ -184,12 +181,10 @@ export class ShareMenuManager { const flyoutRef = createRef(); - const flyoutSession = getFlyoutSession(); - return { flyoutContent: derivativeConfig.flyoutContent({ closeFlyout: () => { - flyoutSession.close(); + getFlyoutSession().close(); onClose(); }, flyoutRef, @@ -217,6 +212,10 @@ export class ShareMenuManager { isServerless: boolean, cb: ( objectTypeMenuItems: ShareConfigs[], + /** + * Returns the flyout session object for the current export integration. + * Hence its return value will be undefined until the flyout has been created. + */ getFlyoutSession: () => ReturnType, onClose: () => void ) => diff --git a/src/platform/plugins/shared/share/public/types.ts b/src/platform/plugins/shared/share/public/types.ts index 6fc78ba64b564..9dee04a40efde 100644 --- a/src/platform/plugins/shared/share/public/types.ts +++ b/src/platform/plugins/shared/share/public/types.ts @@ -15,6 +15,7 @@ import type { EuiContextMenuPanelItemDescriptorEntry } from '@elastic/eui/src/co import type { ILicense } from '@kbn/licensing-types'; import type { Capabilities } from '@kbn/core/public'; import type { TimeRange } from '@kbn/es-query'; +import type { SerializableRecord } from '@kbn/utility-types'; import type { UrlService, LocatorPublic } from '../common/url_service'; import type { BrowserShortUrlClientFactoryCreateParams } from './url_service/short_urls/short_url_client_factory'; import type { BrowserShortUrlClient } from './url_service/short_urls/short_url_client'; @@ -158,6 +159,10 @@ export interface ExportShare requiresSavedState?: boolean; supportedLayoutOptions?: Array<'print'>; renderLayoutOptionSwitch?: boolean; + /** + * indicates if the export integration supports generating it exports with absolute time ranges + */ + supportsAbsoluteTime?: boolean; renderTotalHitsSizeWarning?: (totalHits?: number) => ReactNode | undefined; } & ( | { @@ -253,12 +258,25 @@ export interface ShareUIConfig { }; } -export interface SharingData { +/** + * One locator (typical link/export flows) or several (e.g. CSV reporting). + */ +export type SharingDataLocatorParams

= + | { + id: string; + params: P; + version?: string; + } + | Array<{ + id: string; + params: P; + version?: string; + }>; + +export interface SharingData

+ extends Record { title: string; - locatorParams: { - id: string; - params: Record; - }; + locatorParams: SharingDataLocatorParams

; } export type ShareIntegrationMapKey = `integration-${string}`; @@ -309,7 +327,7 @@ export type ShareableLocatorParams = { * It is possible to use the static function `toggleShareContextMenu` * to render the menu as a popover. * */ -export interface ShareContext { +export interface ShareContext { /** * The type of the object to share. for example lens, dashboard, etc. */ @@ -349,7 +367,7 @@ export interface ShareContext { locator: LocatorPublic; params: ShareableLocatorParams; }; - sharingData: { [key: string]: unknown }; + sharingData: S; isDirty: boolean; onClose: () => void; } @@ -384,7 +402,7 @@ export interface ShareMenuItemLegacy extends ShareMenuItemBase { export interface ExportGenerationOpts { optimizedForPrinting?: boolean; intl: InjectedIntl; - useAbsoluteTime: boolean; + useAbsoluteTime?: boolean; } interface UrlParamExtensionProps { @@ -397,7 +415,16 @@ export interface UrlParamExtension { } /** @public */ -export interface ShowShareMenuOptions extends Omit { +export interface ShowShareMenuOptions< + /** + * Specifies the type of the locator params for the sharing data. + */ + P extends SerializableRecord = SerializableRecord, + /** + * Specifies the type of the sharing data. + */ + S extends Record = Record +> extends Omit & S>, 'onClose'> { asExport?: boolean; anchorElement?: HTMLElement; allowShortUrl: boolean; From 46cb363d4d288aa75257787839c6d1ec78c84447 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Fri, 10 Apr 2026 19:27:25 +0200 Subject: [PATCH 3/9] improve on share integration type to support specifying required sharing data --- .../private/kbn-reporting/common/url.ts | 2 +- .../integrations/csv/csv_export_config.tsx | 8 +-- .../public/share/integrations/csv/index.ts | 3 +- .../public/share/share_context_menu/index.ts | 9 +-- .../private/kbn-reporting/public/types.ts | 2 +- .../plugins/shared/share/public/index.ts | 1 + .../public/services/share_menu_registry.ts | 3 +- .../plugins/shared/share/public/types.ts | 70 ++++++++++++------- 8 files changed, 54 insertions(+), 44 deletions(-) diff --git a/src/platform/packages/private/kbn-reporting/common/url.ts b/src/platform/packages/private/kbn-reporting/common/url.ts index a43affac71812..dc6bfd257b511 100644 --- a/src/platform/packages/private/kbn-reporting/common/url.ts +++ b/src/platform/packages/private/kbn-reporting/common/url.ts @@ -22,7 +22,7 @@ export interface LocatorParams

): ReturnType extends Promise< - infer R - > - ? R - : never => { + }: ShareContext): Awaited< + ReturnType['config']> + > => { const getSearchModeParams = ({ forShareUrl, useAbsoluteTime, diff --git a/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/index.ts b/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/index.ts index c995d7cee6e27..073c7415c434c 100644 --- a/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/index.ts +++ b/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/index.ts @@ -10,13 +10,14 @@ import type { ExportShare, RegisterShareIntegrationArgs } from '@kbn/share-plugin/public'; import type { ExportModalShareOpts } from '../../share_context_menu'; import { checkLicense } from '../../..'; +import type { ReportingCSVSharingData } from '../../../types'; export const reportingCsvExportShareIntegration = ({ apiClient, startServices$, csvConfig, isServerless, -}: ExportModalShareOpts): RegisterShareIntegrationArgs => { +}: ExportModalShareOpts): RegisterShareIntegrationArgs> => { return { id: 'csvReports', groupId: 'export', diff --git a/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/index.ts b/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/index.ts index 8afbf4c77f330..9cce2c927ce8d 100644 --- a/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/index.ts +++ b/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/index.ts @@ -11,7 +11,7 @@ import type * as Rx from 'rxjs'; import type { ApplicationStart, CoreStart } from '@kbn/core/public'; import type { ILicense } from '@kbn/licensing-types'; -import type { SerializableRecord } from '@kbn/utility-types'; +import type { SharingData } from '@kbn/share-plugin/public'; import type { ReportingAPIClient } from '../../reporting_api_client'; import type { ClientConfigType } from '../../types'; @@ -42,13 +42,8 @@ export interface ExportPanelShareOpts { startServices$: Rx.Observable; } -export interface ReportingSharingData { - title: string; +export interface ReportingSharingData extends SharingData { reportingDisabled?: boolean; - locatorParams: { - id: string; - params: SerializableRecord; - }; } export interface JobParamsProviderOptions { diff --git a/src/platform/packages/private/kbn-reporting/public/types.ts b/src/platform/packages/private/kbn-reporting/public/types.ts index de4aa6c00e69a..85c3e6080f00a 100644 --- a/src/platform/packages/private/kbn-reporting/public/types.ts +++ b/src/platform/packages/private/kbn-reporting/public/types.ts @@ -53,7 +53,7 @@ export type ReportingCSVSharingDataLocatorParams = Array< LocatorParams >; -export interface ReportingCSVSharingData extends Exclude { +export interface ReportingCSVSharingData extends SharingData { locatorParams: ReportingCSVSharingDataLocatorParams; isTextBased: boolean; getSearchSource: (args: { diff --git a/src/platform/plugins/shared/share/public/index.ts b/src/platform/plugins/shared/share/public/index.ts index 00a2ed8929fbd..9288f95160cf2 100644 --- a/src/platform/plugins/shared/share/public/index.ts +++ b/src/platform/plugins/shared/share/public/index.ts @@ -32,6 +32,7 @@ export type { RegisterShareIntegrationArgs, ShareableLocatorParams, SharingData, + ShareActionConfigArgs, } from './types'; export type { RedirectOptions } from '../common/url_service'; diff --git a/src/platform/plugins/shared/share/public/services/share_menu_registry.ts b/src/platform/plugins/shared/share/public/services/share_menu_registry.ts index f9169117a3fe4..48c9d6cdcbcab 100644 --- a/src/platform/plugins/shared/share/public/services/share_menu_registry.ts +++ b/src/platform/plugins/shared/share/public/services/share_menu_registry.ts @@ -16,7 +16,6 @@ import type { ShareRegistryPublicApi, ShareActionIntents, InternalShareActionIntent, - ShareIntegration, ShareRegistryApiStart, ShareMenuProviderLegacy, RegisterShareIntegrationArgs, @@ -126,7 +125,7 @@ export class ShareRegistry implements ShareRegistryPublicApi { }); } - private registerShareIntegration( + private registerShareIntegration( ...args: [string, RegisterShareIntegrationArgs] | [RegisterShareIntegrationArgs] ): void { const [shareObject, shareActionIntent] = diff --git a/src/platform/plugins/shared/share/public/types.ts b/src/platform/plugins/shared/share/public/types.ts index 9dee04a40efde..ba7c5011f6faa 100644 --- a/src/platform/plugins/shared/share/public/types.ts +++ b/src/platform/plugins/shared/share/public/types.ts @@ -29,7 +29,7 @@ export interface ShareRegistryApiStart { getLicense: () => ILicense | undefined; } -type ShareActionConfigArgs = ShareContext & +export type ShareActionConfigArgs = ShareContext & Pick; export type ShareTypes = 'link' | 'embed' | 'legacy' | 'integration'; @@ -62,18 +62,19 @@ export interface ShareMenuProviderLegacy { type ShareImplementationFactory< T extends Omit, - C extends Record = Record + C extends Record = Record, + S extends SharingData = SharingData > = T extends 'integration' ? { id: string; groupId?: string; shareType: T; /** - * callback that yields the share configuration for the share as a promise, enables the possibility to dynamically fetch the share configuration + * Callback that yields the configured methods for the current share implementation as a promise, enables the possibility to dynamically fetch the share configuration */ - config: (ctx: ShareActionConfigArgs) => Promise; + config: (ctx: ShareActionConfigArgs) => Promise; /** - * when provided, this method will be used to evaluate if this integration should be available, + * When provided, this method will be used to evaluate if this integration should be available, * given the current license and capabilities of kibana */ prerequisiteCheck?: (args: { @@ -84,10 +85,10 @@ type ShareImplementationFactory< } : { shareType: T; - config: (ctx: ShareActionConfigArgs) => C | null; + config: (ctx: ShareActionConfigArgs) => C | null; }; -// New type definition to extract the config return type +// Type definition to resolve the configured share implementation methods from the implementation factory type ShareImplementation = Omit & { config: T extends ShareImplementationFactory ? R : never; }; @@ -103,7 +104,7 @@ export type LinkShare = ShareImplementationFactory< >; /** - * @description implementation definition for creating a share action for sharing embed links + * @description Implementation definition for creating a share action for sharing embed links */ export type EmbedShare = ShareImplementationFactory< 'embed', @@ -114,14 +115,15 @@ export type EmbedShare = ShareImplementationFactory< >; /** - * @description skeleton definition for implementing a share action integration + * @description Skeleton definition for implementing a share action integration */ export type ShareIntegration< - IntegrationParameters extends Record = Record -> = ShareImplementationFactory<'integration', IntegrationParameters>; + IntegrationParameters extends Record = Record, + S extends SharingData = SharingData +> = ShareImplementationFactory<'integration', IntegrationParameters, S>; /** - * @description implementation definition to support legacy share implementation + * @description Implementation definition to support legacy share implementation */ export interface ShareLegacy { shareType: 'legacy'; @@ -132,7 +134,7 @@ export interface ShareLegacy { /** * @description Share integration implementation definition for performing exports within kibana */ -export interface ExportShare +export interface ExportShare extends ShareIntegration< { /** @@ -179,7 +181,8 @@ export interface ExportShare generateAssetComponent?: never; copyAssetURIConfig?: never; } - ) + ), + S > { groupId: 'export'; } @@ -281,19 +284,32 @@ export interface SharingData

export type ShareIntegrationMapKey = `integration-${string}`; -export interface RegisterShareIntegrationArgs - extends Pick { - getShareIntegrationConfig: I['config']; -} +/** + * Registration payload for a share integration. + * We infer sharing-data `S` from `I` instead of constraining `I extends ShareIntegration` (defaults on + * `ShareIntegration` use `SharingData`, and `config`'s `ctx` is contravariant in `S`, so + * `ExportShare` is not assignable to the default `ShareIntegration`). + */ +export type RegisterShareIntegrationArgs = I extends ShareIntegration< + infer P, + infer S +> + ? P extends Record + ? S extends SharingData + ? Pick & { + getShareIntegrationConfig: (ctx: ShareActionConfigArgs) => ReturnType; + } + : never + : never + : never; export interface ShareRegistryInternalApi { - registerShareIntegration( - shareObject: string, - arg: RegisterShareIntegrationArgs - ): void; - registerShareIntegration(arg: RegisterShareIntegrationArgs): void; + registerShareIntegration(shareObject: string, arg: RegisterShareIntegrationArgs): void; + registerShareIntegration(arg: RegisterShareIntegrationArgs): void; - resolveShareItemsForShareContext(args: ShareContext): Promise; + resolveShareItemsForShareContext( + args: ShareContext & { isServerless: boolean } + ): Promise; } export abstract class ShareRegistryPublicApi { @@ -314,9 +330,9 @@ export type BrowserUrlService = UrlService< BrowserShortUrlClient >; -export type ShareableLocatorParams = { +export type ShareableLocatorParams = SerializableRecord & { timeRange: TimeRange | undefined; -} & Record; +}; /** * @public @@ -364,7 +380,7 @@ export interface ShareContext { */ shareableUrlForSavedObject?: string; shareableUrlLocatorParams?: { - locator: LocatorPublic; + locator: LocatorPublic; params: ShareableLocatorParams; }; sharingData: S; From 2df914680d94110d165167d0d6b62f1f55c37410 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Mon, 13 Apr 2026 12:25:25 +0200 Subject: [PATCH 4/9] update test types to reflect new improved typing for share integrations --- .../services/share_menu_registry.test.ts | 40 +++++++++++-------- .../private/reporting/public/plugin.ts | 3 +- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/platform/plugins/shared/share/public/services/share_menu_registry.test.ts b/src/platform/plugins/shared/share/public/services/share_menu_registry.test.ts index ead8f820a040c..f7763cb91f835 100644 --- a/src/platform/plugins/shared/share/public/services/share_menu_registry.test.ts +++ b/src/platform/plugins/shared/share/public/services/share_menu_registry.test.ts @@ -8,9 +8,17 @@ */ import { ShareRegistry } from './share_menu_registry'; -import type { ShareContext, ShareRegistryApiStart, RegisterShareIntegrationArgs } from '../types'; +import type { + ShareContext, + ShareRegistryApiStart, + RegisterShareIntegrationArgs, + ShareIntegration, + SharingData, +} from '../types'; import { url } from '../mocks'; +type TestShareIntegration = ShareIntegration, SharingData>; + describe('ShareActionsRegistry', () => { const startDeps: ShareRegistryApiStart = { urlService: url, @@ -26,15 +34,15 @@ describe('ShareActionsRegistry', () => { test('throws when registering duplicate id', () => { const shareRegistrySetup = new ShareRegistry().setup(); - shareRegistrySetup.registerShareIntegration({ + shareRegistrySetup.registerShareIntegration({ id: 'csvReports', - getShareIntegrationConfig: () => Promise.resolve({}), + getShareIntegrationConfig: (..._args) => Promise.resolve({}), }); expect(() => - shareRegistrySetup.registerShareIntegration({ + shareRegistrySetup.registerShareIntegration({ id: 'csvReports', - getShareIntegrationConfig: () => Promise.resolve({}), + getShareIntegrationConfig: (..._args) => Promise.resolve({}), }) ).toThrowErrorMatchingInlineSnapshot( `"Share action with type [integration] for app [*] has already been registered."` @@ -51,7 +59,7 @@ describe('ShareActionsRegistry', () => { const { availableIntegrations } = shareRegistry.start(startDeps); // register a global integration without a prerequisite - registerShareIntegration({ + registerShareIntegration({ id: 'csvReports', getShareIntegrationConfig: () => Promise.resolve({}), }); @@ -69,7 +77,7 @@ describe('ShareActionsRegistry', () => { const prerequisiteCheckFn = jest.fn(() => false); // register a global integration with a prerequisiteCheck - registerShareIntegration({ + registerShareIntegration({ id: 'csvReports', getShareIntegrationConfig: () => Promise.resolve({}), prerequisiteCheck: prerequisiteCheckFn, @@ -95,7 +103,7 @@ describe('ShareActionsRegistry', () => { const prerequisiteCheckFn = jest.fn(() => true); // register a global integration with a prerequisiteCheck - registerShareIntegration({ + registerShareIntegration({ id: 'csvReports', getShareIntegrationConfig: () => Promise.resolve({}), prerequisiteCheck: prerequisiteCheckFn, @@ -119,7 +127,7 @@ describe('ShareActionsRegistry', () => { const { availableIntegrations } = shareRegistry.start(startDeps); // register a global integration with a groupId - registerShareIntegration({ + registerShareIntegration({ id: 'csvReports', groupId: 'export', getShareIntegrationConfig: () => Promise.resolve({}), @@ -136,7 +144,7 @@ describe('ShareActionsRegistry', () => { const { availableIntegrations } = shareRegistry.start(startDeps); // register a scoped integration with a groupId - registerShareIntegration('scoped', { + registerShareIntegration('scoped', { id: 'csvReports', groupId: 'export', getShareIntegrationConfig: () => Promise.resolve({}), @@ -205,9 +213,9 @@ describe('ShareActionsRegistry', () => { getShareIntegrationConfig: shareAction3ConfigFactory, }; - registerFunction(shareAction1); - registerFunction(shareAction2); - registerFunction(shareAction3); + registerFunction(shareAction1); + registerFunction(shareAction2); + registerFunction(shareAction3); const context = { objectType: 'anotherRandomShareObjectType' } as ShareContext; const isServerless = false; @@ -279,9 +287,9 @@ describe('ShareActionsRegistry', () => { getShareIntegrationConfig: shareAction3ConfigFactory, }; - registerFunction(context.objectType, shareAction1); - registerFunction('someOtherRandomObjectType', shareAction2); - registerFunction(context.objectType, shareAction3); + registerFunction(context.objectType, shareAction1); + registerFunction('someOtherRandomObjectType', shareAction2); + registerFunction(context.objectType, shareAction3); const { resolveShareItemsForShareContext } = service.start(startDeps); diff --git a/x-pack/platform/plugins/private/reporting/public/plugin.ts b/x-pack/platform/plugins/private/reporting/public/plugin.ts index a29955a28def1..94b7728c818e8 100644 --- a/x-pack/platform/plugins/private/reporting/public/plugin.ts +++ b/x-pack/platform/plugins/private/reporting/public/plugin.ts @@ -34,6 +34,7 @@ import { } from '@kbn/reporting-public/share'; import type { InjectedIntl } from '@kbn/i18n-react'; import type { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; +import type { ReportingCSVSharingData } from '@kbn/reporting-public/types'; import type { ReportingSetup, ReportingStart } from '.'; import { ReportingNotifierStreamHandler as StreamHandler } from './lib/stream_handler'; import type { StartServices } from './types'; @@ -218,7 +219,7 @@ export class ReportingPublicPlugin }); }); - shareSetup.registerShareIntegration( + shareSetup.registerShareIntegration>( 'search', // TODO: export the reporting pdf export provider for registration in the actual plugins that depend on it reportingCsvExportShareIntegration({ From 82ac97e8ac786d4cd4a43d6e095c1ff2dc0f8b51 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Mon, 13 Apr 2026 15:48:02 +0200 Subject: [PATCH 5/9] pass along verison info in location data --- .../main/components/top_nav/app_menu_actions/get_share.tsx | 2 +- src/platform/plugins/shared/discover/public/build_services.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx index f09ee49763487..2f38598538d64 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx @@ -151,7 +151,7 @@ const buildShareOptions = async ({ }, sharingData: { isTextBased: isEsqlMode, - locatorParams: [{ id: locator.id, params }], + locatorParams: [{ id: locator.id, version: services.metadata.version, params }], ...searchSourceSharingData, // CSV reports can be generated without a saved search so we provide a fallback title title: diff --git a/src/platform/plugins/shared/discover/public/build_services.ts b/src/platform/plugins/shared/discover/public/build_services.ts index c9db09f3a598d..95280b4642680 100644 --- a/src/platform/plugins/shared/discover/public/build_services.ts +++ b/src/platform/plugins/shared/discover/public/build_services.ts @@ -124,7 +124,7 @@ export interface DiscoverServices { fieldFormats: FieldFormatsStart; dataViews: DataViewsContract; inspector: InspectorPublicPluginStart; - metadata: { branch: string }; + metadata: { branch: string; version: string }; navigation: NavigationPublicPluginStart; share?: SharePluginStart; urlForwarding: UrlForwardingStart; @@ -230,6 +230,7 @@ export const buildServices = ({ inspector: plugins.inspector, metadata: { branch: context.env.packageInfo.branch, + version: context.env.packageInfo.version, }, navigation: plugins.navigation, share: plugins.share, From 212c4a3ab623d13579e0826b1ab86a4d448dfd27 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Mon, 20 Apr 2026 17:55:36 +0200 Subject: [PATCH 6/9] make csv useAbsoluteTime opt-in --- .../csv/csv_export_config.test.tsx | 57 ++++++++++++ .../integrations/csv/csv_export_config.tsx | 7 +- .../discover/public/__mocks__/services.ts | 1 + .../app_menu_actions/get_share.test.ts | 90 +++++++++++++++++++ .../top_nav/app_menu_actions/get_share.tsx | 2 +- .../export_integrations.tsx | 1 - .../plugins/shared/share/public/types.ts | 1 - 7 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 src/platform/packages/private/kbn-reporting/public/share/integrations/csv/csv_export_config.test.tsx create mode 100644 src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.test.ts diff --git a/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/csv_export_config.test.tsx b/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/csv_export_config.test.tsx new file mode 100644 index 0000000000000..daee064685d5f --- /dev/null +++ b/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/csv_export_config.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 crypto from 'crypto'; +import { getCsvReportParams } from './csv_export_config'; + +describe('csv export config', () => { + describe('getCsvReportParams', () => { + it('should return report params that use absolute time, when useAbsoluteTime is true', () => { + const reportParams = getCsvReportParams({ + sharingData: { + isTextBased: true, + locatorParams: [ + { + id: crypto.randomUUID(), + version: 'test', + params: { + timeRange: { + from: 'now-90d/d', + to: 'now', + }, + }, + }, + ], + getSearchSource: () => ({}), + columns: [], + absoluteTimeRange: { + from: '2021-01-01T00:00:00.000Z', + to: '2021-01-01T00:00:00.000Z', + }, + title: 'test', + }, + useAbsoluteTime: true, + }); + + expect(reportParams).toEqual( + expect.objectContaining({ + locatorParams: expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ + timeRange: { + from: '2021-01-01T00:00:00.000Z', + to: '2021-01-01T00:00:00.000Z', + }, + }), + }), + ]), + }) + ); + }); + }); +}); diff --git a/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/csv_export_config.tsx b/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/csv_export_config.tsx index eacac29c5e293..3065c192acac6 100644 --- a/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/csv_export_config.tsx +++ b/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/csv_export_config.tsx @@ -51,7 +51,7 @@ export const getCsvReportParams: ReportParamsGetter< useAbsoluteTime?: boolean; }, CsvSearchModeParams -> = ({ sharingData, forShareUrl = false, useAbsoluteTime = true }) => { +> = ({ sharingData, forShareUrl = false, useAbsoluteTime = false }) => { const getSearchSource = sharingData.getSearchSource; if (sharingData.isTextBased) { @@ -101,10 +101,10 @@ export const getShareMenuItems = useAbsoluteTime, }); - const generateReportingJobCSV = ({ intl, useAbsoluteTime }: ExportGenerationOpts) => { + const generateReportingJobCSV = ({ intl }: ExportGenerationOpts) => { const { reportType, decoratedJobParams } = getSearchCsvJobParams({ apiClient, - searchModeParams: getSearchModeParams({ useAbsoluteTime }), + searchModeParams: getSearchModeParams({ useAbsoluteTime: true }), title: sharingData.title, }); @@ -175,7 +175,6 @@ export const getShareMenuItems = apiClient, searchModeParams: getSearchModeParams({ forShareUrl: true, - useAbsoluteTime: false, }), title: sharingData.title, }); diff --git a/src/platform/plugins/shared/discover/public/__mocks__/services.ts b/src/platform/plugins/shared/discover/public/__mocks__/services.ts index 09624049a4227..fe14f6df3fe0c 100644 --- a/src/platform/plugins/shared/discover/public/__mocks__/services.ts +++ b/src/platform/plugins/shared/discover/public/__mocks__/services.ts @@ -263,6 +263,7 @@ export function createDiscoverServicesMock(): DiscoverServices { }, metadata: { branch: 'test', + version: 'major.minor.patch', }, theme, storage: new LocalStorageMock({}) as unknown as Storage, diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.test.ts b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.test.ts new file mode 100644 index 0000000000000..4678fc66a8105 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.test.ts @@ -0,0 +1,90 @@ +/* + * 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 { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; +import { createDiscoverServicesMock } from '../../../../../__mocks__/services'; +import { buildShareOptions } from './get_share'; +import { + getDiscoverInternalStateMock, + type InternalStateMockToolkit, +} from '../../../../../__mocks__/discover_state.mock'; +import { FetchStatus } from '../../../../types'; +import { internalStateActions } from '../../../state_management/redux'; + +const mockDiscoverService = createDiscoverServicesMock(); + +describe('getShare', () => { + let toolkit: InternalStateMockToolkit; + + beforeAll(async () => { + toolkit = getDiscoverInternalStateMock({ + services: mockDiscoverService, + persistedDataViews: [dataViewMock], + }); + + await toolkit.initializeTabs(); + await toolkit.initializeSingleTab({ tabId: toolkit.getCurrentTab().id }); + + toolkit.internalState.dispatch( + internalStateActions.setDataView({ + tabId: toolkit.getCurrentTab().id, + dataView: dataViewMock, + }) + ); + }); + + it('should return the correct share options', async () => { + const shareOptions = await buildShareOptions({ + services: mockDiscoverService, + discoverParams: { + dataView: dataViewMock, + isEsqlMode: false, + adHocDataViews: [], + authorizedRuleTypeIds: [], + actions: { + updateAdHocDataViews: jest.fn(), + }, + }, + currentTab: toolkit.getCurrentTab(), + persistedDiscoverSession: undefined, + totalHitsState: { result: 0, fetchStatus: FetchStatus.COMPLETE }, + hasUnsavedChanges: false, + }); + + expect(shareOptions).toEqual( + expect.objectContaining({ + allowShortUrl: false, + shareableUrl: 'http://localhost/', + shareableUrlForSavedObject: '#?_g=()', + sharingData: expect.objectContaining({ + absoluteTimeRange: expect.objectContaining({ + from: expect.any(String), + to: expect.any(String), + }), + locatorParams: expect.arrayContaining([ + expect.objectContaining({ + id: undefined, + version: 'major.minor.patch', + params: expect.objectContaining({ + timeRange: expect.objectContaining({ + from: expect.any(String), + to: expect.any(String), + }), + dataViewId: dataViewMock.id, + }), + }), + ]), + }), + objectId: undefined, + objectType: 'search', + objectTypeAlias: 'Discover session', + }) + ); + }); +}); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx index 2f38598538d64..6ca9272515b54 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx @@ -42,7 +42,7 @@ type DiscoverSharingData = SharingData & ReportingCSVS /** * Builds share options for both share modal and export integrations */ -const buildShareOptions = async ({ +export const buildShareOptions = async ({ discoverParams, services, currentTab, diff --git a/src/platform/plugins/shared/share/public/components/export_integrations/export_integrations.tsx b/src/platform/plugins/shared/share/public/components/export_integrations/export_integrations.tsx index ca68696ee1959..06dc35bb5042e 100644 --- a/src/platform/plugins/shared/share/public/components/export_integrations/export_integrations.tsx +++ b/src/platform/plugins/shared/share/public/components/export_integrations/export_integrations.tsx @@ -229,7 +229,6 @@ export function ManagedExportFlyout({ {exportIntegration.config.copyAssetURIConfig.generateAssetURIValue({ intl, optimizedForPrinting: usePrintLayout, - useAbsoluteTime: false, })} diff --git a/src/platform/plugins/shared/share/public/types.ts b/src/platform/plugins/shared/share/public/types.ts index ba7c5011f6faa..5c17a894f4b4b 100644 --- a/src/platform/plugins/shared/share/public/types.ts +++ b/src/platform/plugins/shared/share/public/types.ts @@ -418,7 +418,6 @@ export interface ShareMenuItemLegacy extends ShareMenuItemBase { export interface ExportGenerationOpts { optimizedForPrinting?: boolean; intl: InjectedIntl; - useAbsoluteTime?: boolean; } interface UrlParamExtensionProps { From e1af8b36b4a6999e763d7c18041661eec2357945 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Mon, 20 Apr 2026 17:58:50 +0200 Subject: [PATCH 7/9] remove unrelated change --- .../locator/filters_from_locator.test.ts | 156 ------------------ .../server/locator/filters_from_locator.ts | 12 +- .../server/locator/resolve_time_field_name.ts | 43 ----- .../time_field_name_from_locator.test.ts | 37 ----- .../locator/time_field_name_from_locator.ts | 3 +- 5 files changed, 7 insertions(+), 244 deletions(-) delete mode 100644 src/platform/plugins/shared/discover/server/locator/filters_from_locator.test.ts delete mode 100644 src/platform/plugins/shared/discover/server/locator/resolve_time_field_name.ts diff --git a/src/platform/plugins/shared/discover/server/locator/filters_from_locator.test.ts b/src/platform/plugins/shared/discover/server/locator/filters_from_locator.test.ts deleted file mode 100644 index 2a19e3715dfd9..0000000000000 --- a/src/platform/plugins/shared/discover/server/locator/filters_from_locator.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { IUiSettingsClient, SavedObjectsClientContract } from '@kbn/core/server'; -import { coreMock, httpServerMock } from '@kbn/core/server/mocks'; -import type { ISearchStartSearchSource } from '@kbn/data-plugin/common'; -import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; -import type { LocatorServicesDeps as Services } from '.'; -import { filtersFromLocatorFactory } from './filters_from_locator'; - -const coreStart = coreMock.createStart(); -let uiSettingsClient: IUiSettingsClient; -let soClient: SavedObjectsClientContract; -let searchSourceStart: ISearchStartSearchSource; -let mockServices: Services; - -beforeAll(async () => { - const dataStartMock = dataPluginMock.createStartContract(); - const request = httpServerMock.createKibanaRequest(); - soClient = coreStart.savedObjects.getScopedClient(request); - uiSettingsClient = coreMock.createStart().uiSettings.asScopedToClient(soClient); - searchSourceStart = await dataStartMock.search.searchSource.asScoped(request); - - mockServices = { - searchSourceStart, - savedObjects: soClient, - uiSettings: uiSettingsClient, - }; -}); - -test('creates a time range filter from dataViewSpec.timeFieldName', async () => { - const params = { - timeRange: { from: '2024-01-01T00:00:00.000Z', to: '2024-01-02T00:00:00.000Z' }, - dataViewSpec: { timeFieldName: '@timestamp' }, - }; - - const filtersFromLocator = filtersFromLocatorFactory(mockServices); - const filters = await filtersFromLocator(params); - - expect(filters).toEqual([ - { - meta: {}, - query: { - range: { - '@timestamp': { - format: 'strict_date_optional_time', - gte: '2024-01-01T00:00:00.000Z', - lte: '2024-01-02T00:00:00.000Z', - }, - }, - }, - }, - ]); -}); - -test('creates a time range filter by resolving dataViewId when dataViewSpec is absent', async () => { - soClient.get = jest.fn().mockResolvedValue({ - id: 'test-data-view-id', - type: 'index-pattern', - attributes: { timeFieldName: '@timestamp' }, - references: [], - }); - - const params = { - timeRange: { from: 'now-15m', to: 'now' }, - dataViewId: 'test-data-view-id', - }; - - const filtersFromLocator = filtersFromLocatorFactory(mockServices); - const filters = await filtersFromLocator(params); - - expect(filters).toEqual([ - { - meta: {}, - query: { - range: { - '@timestamp': { - format: 'strict_date_optional_time', - gte: 'now-15m', - lte: 'now', - }, - }, - }, - }, - ]); - expect(soClient.get).toHaveBeenCalledWith('index-pattern', 'test-data-view-id'); -}); - -test('does not create a time range filter when timeRange is absent', async () => { - const params = { - dataViewSpec: { timeFieldName: '@timestamp' }, - }; - - const filtersFromLocator = filtersFromLocatorFactory(mockServices); - const filters = await filtersFromLocator(params); - - expect(filters).toEqual([]); -}); - -test('does not create a time range filter when timeFieldName cannot be resolved', async () => { - const params = { - timeRange: { from: 'now-15m', to: 'now' }, - dataViewSpec: {}, - }; - - const filtersFromLocator = filtersFromLocatorFactory(mockServices); - const filters = await filtersFromLocator(params); - - expect(filters).toEqual([]); -}); - -test('includes user-provided filters alongside the time range filter', async () => { - const userFilter = { - meta: { alias: 'test' }, - query: { match: { status: 'active' } }, - }; - - const params = { - timeRange: { from: '2024-01-01T00:00:00.000Z', to: '2024-01-02T00:00:00.000Z' }, - dataViewSpec: { timeFieldName: '@timestamp' }, - filters: [userFilter], - }; - - const filtersFromLocator = filtersFromLocatorFactory(mockServices); - const filters = await filtersFromLocator(params); - - expect(filters).toHaveLength(2); - expect(filters[0]).toEqual( - expect.objectContaining({ - query: expect.objectContaining({ - range: expect.objectContaining({ '@timestamp': expect.anything() }), - }), - }) - ); - expect(filters[1]).toBe(userFilter); -}); - -test('gracefully handles dataViewId lookup failure and skips time range filter', async () => { - soClient.get = jest.fn().mockRejectedValue(new Error('Not found')); - - const params = { - timeRange: { from: 'now-15m', to: 'now' }, - dataViewId: 'nonexistent-id', - }; - - const filtersFromLocator = filtersFromLocatorFactory(mockServices); - const filters = await filtersFromLocator(params); - - expect(filters).toEqual([]); -}); diff --git a/src/platform/plugins/shared/discover/server/locator/filters_from_locator.ts b/src/platform/plugins/shared/discover/server/locator/filters_from_locator.ts index 72b2f4b96a6c5..f37a60029d6de 100644 --- a/src/platform/plugins/shared/discover/server/locator/filters_from_locator.ts +++ b/src/platform/plugins/shared/discover/server/locator/filters_from_locator.ts @@ -10,7 +10,6 @@ import type { Filter } from '@kbn/es-query'; import type { LocatorServicesDeps } from '.'; import type { DiscoverAppLocatorParams } from '../../common'; -import { resolveTimeFieldName } from './resolve_time_field_name'; /** * @internal @@ -22,18 +21,19 @@ export const filtersFromLocatorFactory = (services: LocatorServicesDeps) => { const filtersFromLocator = async (params: DiscoverAppLocatorParams): Promise => { const filters: Filter[] = []; - if (params.timeRange) { - const timeFieldName = await resolveTimeFieldName(params, services); + if (params.timeRange && params.dataViewSpec?.timeFieldName) { + const timeRange = params.timeRange; + const timeFieldName = params.dataViewSpec.timeFieldName; - if (timeFieldName) { + if (timeRange) { filters.push({ meta: {}, query: { range: { [timeFieldName]: { format: 'strict_date_optional_time', - gte: params.timeRange.from, - lte: params.timeRange.to, + gte: timeRange.from, + lte: timeRange.to, }, }, }, diff --git a/src/platform/plugins/shared/discover/server/locator/resolve_time_field_name.ts b/src/platform/plugins/shared/discover/server/locator/resolve_time_field_name.ts deleted file mode 100644 index 7ab16a9a2e5bd..0000000000000 --- a/src/platform/plugins/shared/discover/server/locator/resolve_time_field_name.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common'; -import type { LocatorServicesDeps } from '.'; -import type { DiscoverAppLocatorParams } from '../../common'; - -/** - * Resolves the time field name from locator params, falling back to loading - * the data view from saved objects when only a dataViewId is provided. - * - * @internal - */ -export const resolveTimeFieldName = async ( - params: DiscoverAppLocatorParams, - services: LocatorServicesDeps -): Promise => { - if (params.dataViewSpec?.timeFieldName) { - return params.dataViewSpec.timeFieldName; - } - - if (params.dataViewId) { - try { - const dataViewSavedObject = await services.savedObjects.get( - DATA_VIEW_SAVED_OBJECT_TYPE, - params.dataViewId - ); - return (dataViewSavedObject.attributes as Record).timeFieldName as - | string - | undefined; - } catch { - return undefined; - } - } - - return undefined; -}; diff --git a/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.test.ts b/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.test.ts index 0696b42d9154d..181fa9d29e00e 100644 --- a/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.test.ts +++ b/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.test.ts @@ -47,40 +47,3 @@ test(`returns undefined if there is no timeFieldName in DiscoverAppLocatorParams const timeField = await timeFieldNameFromLocatorFn(params); expect(timeField).toBeUndefined(); }); - -test(`resolves timeFieldName from dataViewId when dataViewSpec is not provided`, async () => { - soClient.get = jest.fn().mockResolvedValue({ - id: 'test-data-view-id', - type: 'index-pattern', - attributes: { timeFieldName: '@timestamp' }, - references: [], - }); - - const params = { dataViewId: 'test-data-view-id' }; - const timeFieldNameFromLocatorFn = timeFieldNameFromLocatorFactory(mockServices); - const timeField = await timeFieldNameFromLocatorFn(params); - expect(timeField).toBe('@timestamp'); - expect(soClient.get).toHaveBeenCalledWith('index-pattern', 'test-data-view-id'); -}); - -test(`returns undefined when dataViewId lookup fails`, async () => { - soClient.get = jest.fn().mockRejectedValue(new Error('Not found')); - - const params = { dataViewId: 'nonexistent-id' }; - const timeFieldNameFromLocatorFn = timeFieldNameFromLocatorFactory(mockServices); - const timeField = await timeFieldNameFromLocatorFn(params); - expect(timeField).toBeUndefined(); -}); - -test(`prefers dataViewSpec.timeFieldName over dataViewId lookup`, async () => { - soClient.get = jest.fn(); - - const params = { - dataViewSpec: { timeFieldName: 'event.timestamp' }, - dataViewId: 'test-data-view-id', - }; - const timeFieldNameFromLocatorFn = timeFieldNameFromLocatorFactory(mockServices); - const timeField = await timeFieldNameFromLocatorFn(params); - expect(timeField).toBe('event.timestamp'); - expect(soClient.get).not.toHaveBeenCalled(); -}); diff --git a/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.ts b/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.ts index bbe9a3b81cb4f..336999eeebe40 100644 --- a/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.ts +++ b/src/platform/plugins/shared/discover/server/locator/time_field_name_from_locator.ts @@ -9,7 +9,6 @@ import type { LocatorServicesDeps } from '.'; import type { DiscoverAppLocatorParams } from '../../common'; -import { resolveTimeFieldName } from './resolve_time_field_name'; /** * @internal @@ -21,7 +20,7 @@ export const timeFieldNameFromLocatorFactory = (services: LocatorServicesDeps) = const timeFieldNameFromLocator = async ( params: DiscoverAppLocatorParams ): Promise => { - return resolveTimeFieldName(params, services); + return params.dataViewSpec?.timeFieldName; }; return timeFieldNameFromLocator; From 5e91100da4314b251dbf7898c3b307809e9ec595 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Thu, 23 Apr 2026 15:01:05 +0200 Subject: [PATCH 8/9] add test to assert export generation invocation --- .../csv/csv_export_config.test.tsx | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/csv_export_config.test.tsx b/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/csv_export_config.test.tsx index daee064685d5f..2a9b7fc854dab 100644 --- a/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/csv_export_config.test.tsx +++ b/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/csv_export_config.test.tsx @@ -7,7 +7,17 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ import crypto from 'crypto'; -import { getCsvReportParams } from './csv_export_config'; +import { NEVER } from 'rxjs'; + +jest.mock('../../shared/get_search_csv_job_params', () => ({ + getSearchCsvJobParams: jest.fn(() => ({ + reportType: 'csv_v2', + decoratedJobParams: {}, + })), +})); + +import { getSearchCsvJobParams } from '../../shared/get_search_csv_job_params'; +import { getCsvReportParams, getShareMenuItems } from './csv_export_config'; describe('csv export config', () => { describe('getCsvReportParams', () => { @@ -54,4 +64,71 @@ describe('csv export config', () => { ); }); }); + + describe('getShareMenuItems', () => { + describe('generateReportingJobCSV', () => { + it('invokes getSearchModeParams with useAbsoluteTime set to true', () => { + const absoluteTimeRange = { + from: '2021-01-01T00:00:00.000Z', + to: '2021-01-02T00:00:00.000Z', + }; + const sharingData = { + isTextBased: true, + locatorParams: [ + { + id: crypto.randomUUID(), + version: 'test', + params: { + timeRange: { from: 'now-90d/d', to: 'now' }, + }, + }, + ], + getSearchSource: jest.fn(), + columns: [], + absoluteTimeRange, + title: 'test', + }; + + const apiClient = { + createReportingShareJob: jest.fn(() => new Promise(() => {})), + getManagementLink: jest.fn(), + getReportingPublicJobPath: jest.fn(), + getDecoratedJobParams: jest.fn((params) => params), + }; + + const shareMenu = getShareMenuItems({ + apiClient, + startServices$: NEVER, + csvConfig: { maxRows: 0 }, + isServerless: false, + } as unknown as Parameters[0])({ + objectType: 'search', + sharingData, + shareableUrlLocatorParams: undefined, + } as unknown as Parameters>[0]); + + (getSearchCsvJobParams as jest.Mock).mockClear(); + + // attempt to generate the asset export + void shareMenu.generateAssetExport({ + intl: { formatMessage: jest.fn() }, + } as unknown as Parameters[0]); + + expect(getSearchCsvJobParams).toHaveBeenCalledWith( + expect.objectContaining({ + searchModeParams: { + isEsqlMode: true, + locatorParams: [ + expect.objectContaining({ + params: expect.objectContaining({ + timeRange: absoluteTimeRange, + }), + }), + ], + }, + }) + ); + }); + }); + }); }); From 5fd32b4bf269d7e1fb4e628f01402f20d788f24e Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Thu, 23 Apr 2026 15:02:10 +0200 Subject: [PATCH 9/9] set absolute time range value only for ES|QL sharing --- .../app_menu_actions/get_share.test.ts | 50 ++++++++++++++++++- .../top_nav/app_menu_actions/get_share.tsx | 2 +- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.test.ts b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.test.ts index 4678fc66a8105..95e9dfb8ba700 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.test.ts @@ -39,7 +39,7 @@ describe('getShare', () => { ); }); - it('should return the correct share options', async () => { + it('should return the correct share options, without absolute time range set when in classic mode', async () => { const shareOptions = await buildShareOptions({ services: mockDiscoverService, discoverParams: { @@ -63,6 +63,54 @@ describe('getShare', () => { shareableUrl: 'http://localhost/', shareableUrlForSavedObject: '#?_g=()', sharingData: expect.objectContaining({ + isTextBased: false, + absoluteTimeRange: undefined, + locatorParams: expect.arrayContaining([ + expect.objectContaining({ + id: undefined, + version: 'major.minor.patch', + params: expect.objectContaining({ + timeRange: expect.objectContaining({ + from: expect.any(String), + to: expect.any(String), + }), + dataViewId: dataViewMock.id, + }), + }), + ]), + }), + objectId: undefined, + objectType: 'search', + objectTypeAlias: 'Discover session', + }) + ); + }); + + it('should return the correct share options, with absolute time range set when in ES|QL mode', async () => { + const shareOptions = await buildShareOptions({ + services: mockDiscoverService, + discoverParams: { + dataView: dataViewMock, + isEsqlMode: true, + adHocDataViews: [], + authorizedRuleTypeIds: [], + actions: { + updateAdHocDataViews: jest.fn(), + }, + }, + currentTab: toolkit.getCurrentTab(), + persistedDiscoverSession: undefined, + totalHitsState: { result: 0, fetchStatus: FetchStatus.COMPLETE }, + hasUnsavedChanges: false, + }); + + expect(shareOptions).toEqual( + expect.objectContaining({ + allowShortUrl: false, + shareableUrl: 'http://localhost/', + shareableUrlForSavedObject: '#?_g=()', + sharingData: expect.objectContaining({ + isTextBased: true, absoluteTimeRange: expect.objectContaining({ from: expect.any(String), to: expect.any(String), diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx index 6ca9272515b54..f010616133732 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx @@ -160,7 +160,7 @@ export const buildShareOptions = async ({ defaultMessage: 'Untitled Discover session', }), totalHits: totalHitsState.result || 0, - absoluteTimeRange, + absoluteTimeRange: isEsqlMode ? absoluteTimeRange : undefined, }, isDirty: !persistedDiscoverSession?.id || hasUnsavedChanges, };