From 33a5222b823bd99532defcffdfda94bd3914a336 Mon Sep 17 00:00:00 2001 From: "Eyo O. Eyo" <7893459+eokoneyo@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:00:22 +0200 Subject: [PATCH] Adhere to user selected time range for CSV exports (#255005) ## Summary Closes #216605 This PR adds implementation such that when generating a CSV export within Kibana from the discover app the absolute time range for the current session is used, whilst for retain the currently behaviour for the generated POST URL, this ensures that the export the user gets correlates one to one with the exact content the user is currently viewing. ## Implementation A new property `absoluteTimeRange` is now being passed to the sharing data for the discover app, this property is then used whilst generating a CSV export within Kibana, POST URL retains it's normal behavior of having the value the user selected in the UI, see (https://github.com/elastic/kibana/pull/255005#issuecomment-4084863736) for the rationale behind this decision. ### Visual There is no visual change to the user. --------- Co-authored-by: Elastic Machine (cherry picked from commit dd1ca76250164ccf9ccdce0b0738bd94667bb4e2) # Conflicts: # packages/kbn-optimizer/limits.yml # src/platform/packages/private/kbn-reporting/public/share/integrations/csv/csv_export_config.tsx # src/platform/plugins/shared/discover/moon.yml # src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx # src/platform/plugins/shared/discover/tsconfig.json # src/platform/plugins/shared/share/public/components/export_integrations/export_integrations.tsx # src/platform/plugins/shared/share/public/components/export_integrations/index.ts # src/platform/plugins/shared/share/public/services/share_menu_manager.tsx --- packages/kbn-optimizer/limits.yml | 2 +- .../private/kbn-reporting/public/moon.yml | 2 + .../csv/csv_export_config.test.tsx | 134 ++++++++++++++++++ .../integrations/csv/csv_export_config.tsx | 101 +++++++++---- .../public/share/integrations/csv/index.ts | 3 +- .../public/share/share_context_menu/index.ts | 8 +- .../kbn-reporting/public/tsconfig.json | 2 + .../private/kbn-reporting/public/types.ts | 25 +++- src/platform/plugins/shared/discover/moon.yml | 1 + .../discover/public/__mocks__/services.ts | 1 + .../top_nav/app_menu_actions/get_share.tsx | 12 +- .../shared/discover/public/build_services.ts | 3 +- .../plugins/shared/discover/tsconfig.json | 3 +- .../export_integrations.test.tsx | 6 +- .../export_integrations.tsx | 14 +- .../components/export_integrations/index.ts | 6 +- .../public/components/share_context_menu.tsx | 4 +- .../public/components/share_tabs.test.tsx | 2 +- .../tabs/embed/embed_content.test.tsx | 2 +- .../tabs/link/link_content.test.tsx | 2 +- .../public/components/url_panel_content.tsx | 4 +- .../plugins/shared/share/public/index.ts | 5 +- .../services/share_menu_registry.test.ts | 40 +++--- .../public/services/share_menu_registry.ts | 3 +- .../plugins/shared/share/public/types.ts | 115 ++++++++++----- .../private/reporting/public/plugin.ts | 3 +- 26 files changed, 388 insertions(+), 115 deletions(-) create mode 100644 src/platform/packages/private/kbn-reporting/public/share/integrations/csv/csv_export_config.test.tsx diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 135f4f89904c0..fcc3cd1c5658a 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -168,7 +168,7 @@ pageLoadAssetSize: serverlessSearch: 26393 serverlessWorkplaceAI: 6736 sessionView: 47912 - share: 58677 + share: 64618 slo: 36645 snapshotRestore: 22068 spaces: 30665 diff --git a/src/platform/packages/private/kbn-reporting/public/moon.yml b/src/platform/packages/private/kbn-reporting/public/moon.yml index fe2c872314cc7..f47f0a08172d7 100644 --- a/src/platform/packages/private/kbn-reporting/public/moon.yml +++ b/src/platform/packages/private/kbn-reporting/public/moon.yml @@ -39,6 +39,8 @@ dependsOn: - '@kbn/ui-actions-plugin' - '@kbn/actions-plugin' - '@kbn/licensing-types' + - '@kbn/es-query' + - '@kbn/utility-types' tags: - shared-browser - package 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..2a9b7fc854dab --- /dev/null +++ b/src/platform/packages/private/kbn-reporting/public/share/integrations/csv/csv_export_config.test.tsx @@ -0,0 +1,134 @@ +/* + * 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 { 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', () => { + 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', + }, + }), + }), + ]), + }) + ); + }); + }); + + 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, + }), + }), + ], + }, + }) + ); + }); + }); + }); +}); 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 e7e081bf962dc..964675b1d7506 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 @@ -11,41 +11,64 @@ import React from 'react'; import { firstValueFrom } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { toMountPoint } from '@kbn/react-kibana-mount'; -import type { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; -import type { InjectedIntl } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react'; import type { ShareContext, ExportShare } from '@kbn/share-plugin/public'; import type { LocatorParams } from '@kbn/reporting-common/types'; import { EuiCallOut, EuiText } from '@elastic/eui'; -import type { ReportParamsGetter, ReportParamsGetterOptions } from '../../../types'; +import type { TimeRange } from '@kbn/es-query'; +import type { ExportGenerationOpts } from '@kbn/share-plugin/public/types'; +import type { + ReportingCSVSharingData, + ReportParamsGetter, + ReportParamsGetterOptions, +} from '../../../types'; import type { CsvSearchModeParams } from '../../shared/get_search_csv_job_params'; import { getSearchCsvJobParams } from '../../shared/get_search_csv_job_params'; import type { ExportModalShareOpts } from '../../share_context_menu'; +const toAbsoluteTimeRange = ( + locatorParams: LocatorParams[], + absoluteTimeRange: TimeRange | undefined +): 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 = false }) => { + 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,28 @@ export const getShareMenuItems = ({ objectType, sharingData, - }: ShareContext): ReturnType extends Promise ? R : never => { - const getSearchModeParams = (forShareUrl?: boolean): CsvSearchModeParams => - getCsvReportParams({ sharingData, forShareUrl }); + shareableUrlLocatorParams, + }: ShareContext): Awaited< + ReturnType['config']> + > => { + const getSearchModeParams = ({ + forShareUrl, + useAbsoluteTime, + }: { + forShareUrl?: boolean; + useAbsoluteTime?: boolean; + } = {}): CsvSearchModeParams => + getCsvReportParams({ + sharingData, + forShareUrl, + useAbsoluteTime, + }); - const generateReportingJobCSV = ({ intl }: { intl: InjectedIntl }) => { + const generateReportingJobCSV = ({ intl }: ExportGenerationOpts) => { const { reportType, decoratedJobParams } = getSearchCsvJobParams({ apiClient, - searchModeParams: getSearchModeParams(false), - title: sharingData.title as string, + searchModeParams: getSearchModeParams({ useAbsoluteTime: true }), + title: sharingData.title, }); return firstValueFrom(startServices$).then(([startServices]) => { @@ -128,21 +164,30 @@ 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, + }), + 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: 'tableDensityNormal', + 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/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 ce37f02a0a7cd..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,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 { SharingData } from '@kbn/share-plugin/public'; import type { ReportingAPIClient } from '../../reporting_api_client'; import type { ClientConfigType } from '../../types'; @@ -41,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: unknown; - }; } export interface JobParamsProviderOptions { 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..85c3e6080f00a 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 SharingData { + 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 12f5f7e611538..0f22060197202 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -128,6 +128,7 @@ dependsOn: - '@kbn/react-query' - '@kbn/lens-common' - '@kbn/shared-ux-error-boundary' + - '@kbn/reporting-public' tags: - plugin - prod diff --git a/src/platform/plugins/shared/discover/public/__mocks__/services.ts b/src/platform/plugins/shared/discover/public/__mocks__/services.ts index ef0782bb090e8..da56e0636ce10 100644 --- a/src/platform/plugins/shared/discover/public/__mocks__/services.ts +++ b/src/platform/plugins/shared/discover/public/__mocks__/services.ts @@ -224,6 +224,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.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx index 63a0da30fde14..ad0a9c42d365a 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 @@ -12,8 +12,9 @@ import { AppMenuActionId, AppMenuActionType } 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 { SharingData } from '@kbn/share-plugin/public/types'; +import type { ReportingCSVSharingData } from '@kbn/reporting-public/types'; import type { DiscoverStateContainer } from '../../../state_management/discover_state'; import type { DataTotalHitsMsg } from '../../../state_management/discover_data_state_container'; import { getSharingData, showPublicUrlSwitch } from '../../../../../utils/get_sharing_data'; @@ -22,6 +23,11 @@ import type { AppMenuDiscoverParams } from './types'; import type { DiscoverServices } from '../../../../../build_services'; import type { TabState } from '../../../state_management/redux/types'; +/** + * Specifies an explicit type for the sharing data of the Discover app. + */ +type DiscoverSharingData = SharingData & ReportingCSVSharingData; + export const getShareAppMenuItem = ({ discoverParams, services, @@ -64,11 +70,12 @@ export const getShareAppMenuItem = ({ const { locator, discoverFeatureFlags } = 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() @@ -153,6 +160,7 @@ export const getShareAppMenuItem = ({ defaultMessage: 'Untitled Discover session', }), totalHits: totalHitsState.result || 0, + absoluteTimeRange: isEsqlMode ? absoluteTimeRange : undefined, }, isDirty: !persistedDiscoverSession?.id || hasUnsavedChanges, onClose: () => { diff --git a/src/platform/plugins/shared/discover/public/build_services.ts b/src/platform/plugins/shared/discover/public/build_services.ts index 78a7b50c7bf45..688c46f2ae955 100644 --- a/src/platform/plugins/shared/discover/public/build_services.ts +++ b/src/platform/plugins/shared/discover/public/build_services.ts @@ -113,7 +113,7 @@ export interface DiscoverServices { fieldFormats: FieldFormatsStart; dataViews: DataViewsContract; inspector: InspectorPublicPluginStart; - metadata: { branch: string }; + metadata: { branch: string; version: string }; navigation: NavigationPublicPluginStart; share?: SharePluginStart; urlForwarding: UrlForwardingStart; @@ -211,6 +211,7 @@ export const buildServices = ({ inspector: plugins.inspector, metadata: { branch: context.env.packageInfo.branch, + version: context.env.packageInfo.version, }, navigation: plugins.navigation, share: plugins.share, diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index 02837e8d29f37..51140acf48e76 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -121,7 +121,8 @@ "@kbn/shared-ux-link-redirect-app", "@kbn/react-query", "@kbn/lens-common", - "@kbn/shared-ux-error-boundary" + "@kbn/shared-ux-error-boundary", + "@kbn/reporting-public" ], "exclude": ["target/**/*"] } 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 }) => { @@ -59,7 +59,7 @@ interface LayoutOptionsProps { printLayoutChange: (evt: EuiSwitchEvent) => void; } -interface ManagedFlyoutProps { +export interface ManagedExportFlyoutProps { exportIntegration: ExportShareConfig; intl: InjectedIntl; isDirty: boolean; @@ -75,6 +75,7 @@ interface ManagedFlyoutProps { sharingData: { [key: string]: unknown; }; + shareableUrlLocatorParams?: ShareContext['shareableUrlLocatorParams']; } function LayoutOptionsSwitch({ usePrintLayout, printLayoutChange }: LayoutOptionsProps) { @@ -128,7 +129,7 @@ function LayoutOptionsSwitch({ usePrintLayout, printLayoutChange }: LayoutOption ); } -export function ManagedFlyout({ +export function ManagedExportFlyout({ exportIntegration, intl, isDirty, @@ -140,7 +141,7 @@ export function ManagedFlyout({ onSave, isSaving, sharingData, -}: ManagedFlyoutProps) { +}: ManagedExportFlyoutProps) { const [usePrintLayout, setPrintLayout] = useState(false); const [isCreatingExport, setIsCreatingExport] = useState(false); @@ -296,6 +297,7 @@ function ExportMenuPopover({ intl }: ExportMenuProps) { objectTypeAlias, objectTypeMeta, sharingData, + shareableUrlLocatorParams, } = useShareTypeContext('integration', 'export'); const { shareMenuItems: exportDerivatives } = useShareTypeContext( 'integration', @@ -314,6 +316,7 @@ function ExportMenuPopover({ intl }: ExportMenuProps) { id: string; group: keyof typeof selectionOptions.current; }>(); + const selectedMenuItem = useMemo(() => { let result: ExportShareConfig | ExportShareDerivativesConfig | null = null; @@ -465,7 +468,7 @@ function ExportMenuPopover({ intl }: ExportMenuProps) { }} /> {selectedMenuItemMeta!.group === 'export' ? ( - ) : ( (selectedMenuItem as ExportShareDerivativesConfig)?.config.flyoutContent({ diff --git a/src/platform/plugins/shared/share/public/components/export_integrations/index.ts b/src/platform/plugins/shared/share/public/components/export_integrations/index.ts index 7ef44445d5a0a..60e0d7ca72693 100644 --- a/src/platform/plugins/shared/share/public/components/export_integrations/index.ts +++ b/src/platform/plugins/shared/share/public/components/export_integrations/index.ts @@ -7,4 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { ExportMenu } from './export_integrations'; +export { + ExportMenu, + ManagedExportFlyout, + type ManagedExportFlyoutProps, +} from './export_integrations'; 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 9280801896984..f1cecfff143ca 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/share_tabs.test.tsx b/src/platform/plugins/shared/share/public/components/share_tabs.test.tsx index 53ea598af3afa..8d7617c133425 100644 --- a/src/platform/plugins/shared/share/public/components/share_tabs.test.tsx +++ b/src/platform/plugins/shared/share/public/components/share_tabs.test.tsx @@ -81,7 +81,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(), }; diff --git a/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.test.tsx b/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.test.tsx index 590c393991198..a191c075cdb57 100644 --- a/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.test.tsx +++ b/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.test.tsx @@ -50,7 +50,7 @@ const mockShareContext: IShareContext = { title: 'title', config: {}, }, - sharingData: { title: 'title', url: 'url' }, + sharingData: { title: 'title', url: 'url', locatorParams: { id: 'test', params: {} } }, }; const renderComponent = ( diff --git a/src/platform/plugins/shared/share/public/components/tabs/link/link_content.test.tsx b/src/platform/plugins/shared/share/public/components/tabs/link/link_content.test.tsx index 14b934076c9e7..338ccd73acafd 100644 --- a/src/platform/plugins/shared/share/public/components/tabs/link/link_content.test.tsx +++ b/src/platform/plugins/shared/share/public/components/tabs/link/link_content.test.tsx @@ -33,7 +33,7 @@ const mockShareContext: IShareContext = { title: 'title', config: {}, }, - sharingData: { title: 'title', url: 'url' }, + sharingData: { title: 'title', url: 'url', locatorParams: { id: 'test', params: {} } }, }; const renderComponent = ( diff --git a/src/platform/plugins/shared/share/public/components/url_panel_content.tsx b/src/platform/plugins/shared/share/public/components/url_panel_content.tsx index 03460edb9dd12..045596294eb34 100644 --- a/src/platform/plugins/shared/share/public/components/url_panel_content.tsx +++ b/src/platform/plugins/shared/share/public/components/url_panel_content.tsx @@ -32,7 +32,7 @@ import { i18n } from '@kbn/i18n'; import type { Capabilities } from '@kbn/core/public'; import type { LocatorPublic } from '../../common'; -import type { ShareableUrlLocatorParams, UrlParamExtension } from '../types'; +import type { ShareableLocatorParams, UrlParamExtension } from '../types'; import type { AnonymousAccessServiceContract, AnonymousAccessState, @@ -48,7 +48,7 @@ export interface UrlPanelContentProps { shareableUrlForSavedObject?: string; shareableUrlLocatorParams?: { locator: LocatorPublic; - 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..9288f95160cf2 100644 --- a/src/platform/plugins/shared/share/public/index.ts +++ b/src/platform/plugins/shared/share/public/index.ts @@ -30,7 +30,9 @@ export type { ExportShareConfig, ExportShareDerivatives, RegisterShareIntegrationArgs, - ShareableUrlLocatorParams, + ShareableLocatorParams, + SharingData, + ShareActionConfigArgs, } from './types'; export type { RedirectOptions } from '../common/url_service'; @@ -40,6 +42,7 @@ import { SharePlugin } from './plugin'; export { downloadMultipleAs, downloadFileAs } from './lib/download_as'; export type { DownloadableContent } from './lib/download_as'; +export { convertRelativeTimeStringToAbsoluteTimeString } from './lib/time_utils'; export function plugin(ctx: PluginInitializerContext) { return new SharePlugin(ctx); 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/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 5edf7d4566564..5c17a894f4b4b 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'; @@ -28,7 +29,7 @@ export interface ShareRegistryApiStart { getLicense: () => ILicense | undefined; } -type ShareActionConfigArgs = ShareContext & +export type ShareActionConfigArgs = ShareContext & Pick; export type ShareTypes = 'link' | 'embed' | 'legacy' | 'integration'; @@ -61,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: { @@ -83,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; }; @@ -102,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', @@ -113,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'; @@ -131,7 +134,7 @@ export interface ShareLegacy { /** * @description Share integration implementation definition for performing exports within kibana */ -export interface ExportShare +export interface ExportShare extends ShareIntegration< { /** @@ -158,6 +161,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; } & ( | { @@ -174,7 +181,8 @@ export interface ExportShare generateAssetComponent?: never; copyAssetURIConfig?: never; } - ) + ), + S > { groupId: 'export'; } @@ -253,29 +261,55 @@ 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}`; -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 { @@ -296,9 +330,9 @@ export type BrowserUrlService = UrlService< BrowserShortUrlClient >; -export type ShareableUrlLocatorParams = { +export type ShareableLocatorParams = SerializableRecord & { timeRange: TimeRange | undefined; -} & Record; +}; /** * @public @@ -309,7 +343,7 @@ export type ShareableUrlLocatorParams = { * 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. */ @@ -346,10 +380,10 @@ export interface ShareContext { */ shareableUrlForSavedObject?: string; shareableUrlLocatorParams?: { - locator: LocatorPublic; - params: ShareableUrlLocatorParams; + locator: LocatorPublic; + params: ShareableLocatorParams; }; - sharingData: { [key: string]: unknown }; + sharingData: S; isDirty: boolean; onClose: () => void; } @@ -396,7 +430,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; diff --git a/x-pack/platform/plugins/private/reporting/public/plugin.ts b/x-pack/platform/plugins/private/reporting/public/plugin.ts index 54a8410297c66..af34844375f67 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({