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 21e7d49f1392c..9ad77da572973 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 @@ -29,8 +29,6 @@ export type StartServices = [ export interface ExportModalShareOpts { apiClient: ReportingAPIClient; usesUiCapabilities: boolean; - license: ILicense; - application: ApplicationStart; startServices$: Rx.Observable; } diff --git a/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_csv_modal_reporting.tsx b/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_csv_modal_reporting.tsx index 04ddbd9d6fc13..0249bdc019d25 100644 --- a/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_csv_modal_reporting.tsx +++ b/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_csv_modal_reporting.tsx @@ -21,8 +21,6 @@ import { checkLicense } from '../..'; export const reportingCsvExportProvider = ({ apiClient, - application, - license, usesUiCapabilities, startServices$, }: ExportModalShareOpts): ExportShare => { @@ -30,22 +28,6 @@ export const reportingCsvExportProvider = ({ objectType, sharingData, }: ShareContext): ReturnType => { - const licenseCheck = checkLicense(license.check('reporting', 'basic')); - const licenseToolTipContent = licenseCheck.message; - const licenseHasCsvReporting = licenseCheck.showLinks; - const licenseDisabled = !licenseCheck.enableLinks; - - let capabilityHasCsvReporting = false; - if (usesUiCapabilities) { - capabilityHasCsvReporting = application.capabilities.discover?.generateCsv === true; - } else { - capabilityHasCsvReporting = true; // deprecated - } - - if (!(licenseHasCsvReporting && capabilityHasCsvReporting)) { - return null; - } - const getSearchSource = sharingData.getSearchSource as ({ addGlobalTimeFilter, absoluteTime, @@ -148,12 +130,9 @@ export const reportingCsvExportProvider = ({ return { name: panelTitle, - toolTipContent: licenseToolTipContent, exportType: reportType, label: 'CSV', - disabled: licenseDisabled, generateAssetExport: generateReportingJobCSV, - generateAssetURIValue: () => absoluteUrl, helpText: ( ), - generateExportButton: ( + generateExportButtonLabel: ( ), - renderCopyURIButton: true, + copyAssetURIConfig: { + headingText: i18n.translate('reporting.export.csv.exportFlyout.csvExportCopyUriHeading', { + defaultMessage: 'Post URL', + }), + helpText: i18n.translate('reporting.export.csv.exportFlyout.csvExportCopyUriHelpText', { + defaultMessage: + 'Allows to generate selected file format programmatically outside Kibana or in Watcher.', + }), + contentType: 'text', + generateAssetURIValue: () => absoluteUrl, + }, }; }; return { shareType: 'integration', - id: 'csvReportsModal', + id: 'csvReports', groupId: 'export', config: getShareMenuItems, + prerequisiteCheck: ({ license, capabilities }) => { + if (!license) { + return false; + } + + const licenseCheck = checkLicense(license.check('reporting', 'basic')); + + const licenseHasCsvReporting = licenseCheck.showLinks; + + let capabilityHasCsvReporting = false; + + if (usesUiCapabilities) { + capabilityHasCsvReporting = capabilities.discover?.generateCsv === true; + } else { + capabilityHasCsvReporting = true; // deprecated + } + + if (!(licenseHasCsvReporting && capabilityHasCsvReporting)) { + return false; + } + + return true; + }, }; }; diff --git a/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_pdf_png_modal_reporting.tsx b/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_pdf_png_modal_reporting.tsx index 806d4b064f040..c0edc0108d484 100644 --- a/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_pdf_png_modal_reporting.tsx +++ b/src/platform/packages/private/kbn-reporting/public/share/share_context_menu/register_pdf_png_modal_reporting.tsx @@ -41,11 +41,11 @@ const getJobParams = (opts: JobParamsProviderOptions, type: 'pngV2' | 'printable export const reportingPDFExportProvider = ({ apiClient, - license, - application, usesUiCapabilities, startServices$, }: ExportModalShareOpts): ExportShare => { + const supportedObjectTypes = ['dashboard', 'visualization', 'lens']; + const getShareMenuItems = ({ objectType, objectId, @@ -55,47 +55,6 @@ export const reportingPDFExportProvider = ({ shareableUrlForSavedObject, ...shareOpts }: ShareContext): ReturnType => { - const { enableLinks, showLinks, message } = checkLicense(license.check('reporting', 'gold')); - const licenseToolTipContent = message; - const licenseHasScreenshotReporting = showLinks; - const licenseDisabled = !enableLinks; - - let capabilityHasDashboardScreenshotReporting = false; - let capabilityHasVisualizeScreenshotReporting = false; - if (usesUiCapabilities) { - capabilityHasDashboardScreenshotReporting = - application.capabilities.dashboard?.generateScreenshot === true; - capabilityHasVisualizeScreenshotReporting = - application.capabilities.visualize?.generateScreenshot === true; - } else { - // deprecated - capabilityHasDashboardScreenshotReporting = true; - capabilityHasVisualizeScreenshotReporting = true; - } - - if (!licenseHasScreenshotReporting) { - return null; - } - - // for lens png pdf and csv are combined into one modal - const isSupportedType = ['dashboard', 'visualization', 'lens'].includes(objectType); - - if (!isSupportedType) { - return null; - } - - if (objectType === 'dashboard' && !capabilityHasDashboardScreenshotReporting) { - return null; - } - - if ( - isSupportedType && - !capabilityHasVisualizeScreenshotReporting && - !capabilityHasDashboardScreenshotReporting - ) { - return null; - } - const { sharingData } = shareOpts as unknown as { sharingData: ReportingSharingData }; const jobProviderOptions: JobParamsProviderOptions = { @@ -173,15 +132,30 @@ export const reportingPDFExportProvider = ({ name: i18n.translate('reporting.shareContextMenu.ExportsButtonLabel', { defaultMessage: 'PDF', }), - toolTipContent: licenseToolTipContent, - disabled: licenseDisabled || sharingData.reportingDisabled, + icon: 'document', + disabled: sharingData.reportingDisabled, label: 'PDF' as const, generateAssetExport: generateReportPDF, - generateAssetURIValue: generateExportUrlPDF, exportType: 'printablePdfV2', requiresSavedState, renderLayoutOptionSwitch: objectType === 'dashboard', - renderCopyURIButton: true, + copyAssetURIConfig: { + headingText: i18n.translate( + 'reporting.shareContextMenu.copyUriModal.pdfExportCopyUriHeading', + { + defaultMessage: 'Post URL', + } + ), + helpText: i18n.translate( + 'reporting.shareContextMenu.copyUriModal.pdfExportCopyUriHelpText', + { + defaultMessage: + 'Allows to generate selected file format programmatically outside Kibana or in Watcher.', + } + ), + contentType: 'text', + generateAssetURIValue: generateExportUrlPDF, + }, }; }; @@ -190,16 +164,62 @@ export const reportingPDFExportProvider = ({ shareType: 'integration', groupId: 'export', config: getShareMenuItems, + prerequisiteCheck({ license, capabilities, objectType }) { + if (!license) { + return false; + } + + let isSupportedType: boolean; + + if (!(isSupportedType = supportedObjectTypes.includes(objectType))) { + return false; + } + + const { showLinks: licenseHasScreenshotReporting } = checkLicense( + license.check('reporting', 'gold') + ); + + let capabilityHasDashboardScreenshotReporting = false; + let capabilityHasVisualizeScreenshotReporting = false; + if (usesUiCapabilities) { + capabilityHasDashboardScreenshotReporting = + capabilities.dashboard?.generateScreenshot === true; + capabilityHasVisualizeScreenshotReporting = + capabilities.visualize?.generateScreenshot === true; + } else { + // deprecated + capabilityHasDashboardScreenshotReporting = true; + capabilityHasVisualizeScreenshotReporting = true; + } + + if (!licenseHasScreenshotReporting) { + return false; + } + + if (objectType === 'dashboard' && !capabilityHasDashboardScreenshotReporting) { + return false; + } + + if ( + isSupportedType && + !capabilityHasVisualizeScreenshotReporting && + !capabilityHasDashboardScreenshotReporting + ) { + return false; + } + + return true; + }, }; }; export const reportingPNGExportProvider = ({ apiClient, - license, - application, usesUiCapabilities, startServices$, }: ExportModalShareOpts): ExportShare => { + const supportedObjectTypes = ['dashboard', 'visualization', 'lens']; + const getShareMenuItems = ({ objectType, objectId, @@ -208,47 +228,7 @@ export const reportingPNGExportProvider = ({ shareableUrl, shareableUrlForSavedObject, ...shareOpts - }: ShareContext): ReturnType | null => { - const { enableLinks, showLinks, message } = checkLicense(license.check('reporting', 'gold')); - const licenseToolTipContent = message; - const licenseHasScreenshotReporting = showLinks; - const licenseDisabled = !enableLinks; - - let capabilityHasDashboardScreenshotReporting = false; - let capabilityHasVisualizeScreenshotReporting = false; - if (usesUiCapabilities) { - capabilityHasDashboardScreenshotReporting = - application.capabilities.dashboard?.generateScreenshot === true; - capabilityHasVisualizeScreenshotReporting = - application.capabilities.visualize?.generateScreenshot === true; - } else { - // deprecated - capabilityHasDashboardScreenshotReporting = true; - capabilityHasVisualizeScreenshotReporting = true; - } - - if (!licenseHasScreenshotReporting) { - return null; - } - // for lens png pdf and csv are combined into one modal - const isSupportedType = ['dashboard', 'visualization', 'lens'].includes(objectType); - - if (!isSupportedType) { - return null; - } - - if (objectType === 'dashboard' && !capabilityHasDashboardScreenshotReporting) { - return null; - } - - if ( - isSupportedType && - !capabilityHasVisualizeScreenshotReporting && - !capabilityHasDashboardScreenshotReporting - ) { - return null; - } - + }: ShareContext): ReturnType => { const { sharingData } = shareOpts as unknown as { sharingData: ReportingSharingData }; const jobProviderOptions: JobParamsProviderOptions = { @@ -326,14 +306,29 @@ export const reportingPNGExportProvider = ({ name: i18n.translate('reporting.shareContextMenu.ExportsButtonLabelPNG', { defaultMessage: 'PNG export', }), - toolTipContent: licenseToolTipContent, - disabled: licenseDisabled || sharingData.reportingDisabled, + icon: 'image', + disabled: sharingData.reportingDisabled, label: 'PNG' as const, generateAssetExport: generateReportPNG, - generateAssetURIValue: generateExportUrlPNG, exportType: 'pngV2', requiresSavedState, - renderCopyURIButton: true, + copyAssetURIConfig: { + headingText: i18n.translate( + 'reporting.shareContextMenu.copyUriModal.pngExportCopyUriHeading', + { + defaultMessage: 'Post URL', + } + ), + helpText: i18n.translate( + 'reporting.shareContextMenu.copyUriModal.pngExportCopyUriHelpText', + { + defaultMessage: + 'Allows to generate selected file format programmatically outside Kibana or in Watcher.', + } + ), + contentType: 'text', + generateAssetURIValue: generateExportUrlPNG, + }, }; }; @@ -342,5 +337,50 @@ export const reportingPNGExportProvider = ({ groupId: 'export', id: 'imageReports', config: getShareMenuItems, + prerequisiteCheck({ license, capabilities, objectType }) { + if (!license) { + return false; + } + + let isSupportedType: boolean; + + if (!(isSupportedType = supportedObjectTypes.includes(objectType))) { + return false; + } + + const { showLinks } = checkLicense(license.check('reporting', 'gold')); + const licenseHasScreenshotReporting = showLinks; + + let capabilityHasDashboardScreenshotReporting = false; + let capabilityHasVisualizeScreenshotReporting = false; + if (usesUiCapabilities) { + capabilityHasDashboardScreenshotReporting = + capabilities.dashboard?.generateScreenshot === true; + capabilityHasVisualizeScreenshotReporting = + capabilities.visualize?.generateScreenshot === true; + } else { + // deprecated + capabilityHasDashboardScreenshotReporting = true; + capabilityHasVisualizeScreenshotReporting = true; + } + + if (!licenseHasScreenshotReporting) { + return false; + } + + if (objectType === 'dashboard' && !capabilityHasDashboardScreenshotReporting) { + return false; + } + + if ( + isSupportedType && + !capabilityHasVisualizeScreenshotReporting && + !capabilityHasDashboardScreenshotReporting + ) { + return false; + } + + return true; + }, }; }; diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts index d5cd1bde16be7..b8e7d463350d8 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts @@ -38,6 +38,7 @@ export enum AppMenuActionId { new = 'new', open = 'open', share = 'share', + export = 'export', alerts = 'alerts', inspect = 'inspect', createRule = 'createRule', diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/_dashboard_app_strings.ts b/src/platform/plugins/shared/dashboard/public/dashboard_app/_dashboard_app_strings.ts index 25017f2fcc3ef..16e865a1b6155 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/_dashboard_app_strings.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/_dashboard_app_strings.ts @@ -214,6 +214,14 @@ export const topNavStrings = { defaultMessage: 'Switch to view-only mode', }), }, + export: { + label: i18n.translate('dashboard.topNave.exportButtonAriaLabel', { + defaultMessage: 'Export', + }), + description: i18n.translate('dashboard.topNave.exportConfigDescription', { + defaultMessage: 'Export dashboard', + }), + }, share: { label: i18n.translate('dashboard.topNave.shareButtonAriaLabel', { defaultMessage: 'share', diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx index eb999b3f63f60..5fe7644cb5e64 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx @@ -31,6 +31,7 @@ import { dashboardUrlParams } from '../../dashboard_router'; const showFilterBarId = 'showFilterBar'; export interface ShowShareModalProps { + asExport?: boolean; isDirty: boolean; savedObjectId?: string; dashboardTitle?: string; @@ -47,6 +48,7 @@ export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => export function ShowShareModal({ isDirty, + asExport, anchorElement, savedObjectId, dashboardTitle, @@ -152,6 +154,7 @@ export function ShowShareModal({ anchorElement, allowShortUrl, shareableUrl, + asExport, objectId: savedObjectId, objectType: 'dashboard', objectTypeMeta: { @@ -204,6 +207,48 @@ export function ShowShareModal({ ], computeAnonymousCapabilities: showPublicUrlSwitch, }, + integration: { + export: { + pdfReports: { + draftModeCallOut: ( + + } + > + + + ), + }, + imageReports: { + draftModeCallOut: ( + + } + > + + + ), + }, + }, + }, }, }, sharingData: { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx index 7b6688273d223..52d132b8d5582 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx @@ -55,8 +55,9 @@ export const useDashboardMenuItems = ({ * Show the Dashboard app's share menu */ const showShare = useCallback( - (anchorElement: HTMLElement) => { + (anchorElement: HTMLElement, asExport?: boolean) => { ShowShareModal({ + asExport, dashboardTitle, anchorElement, savedObjectId: lastSavedId, @@ -189,11 +190,23 @@ export const useDashboardMenuItems = ({ share: { ...topNavStrings.share, id: 'share', + iconType: 'share', + iconOnly: true, testId: 'shareTopNavButton', disableButton: disableTopNav, run: showShare, } as TopNavMenuData, + export: { + ...topNavStrings.export, + id: 'export', + iconType: 'download', + iconOnly: true, + testId: 'exportTopNavButton', + disableButton: disableTopNav, + run: (anchorElement) => showShare(anchorElement, true), + } as TopNavMenuData, + settings: { ...topNavStrings.settings, id: 'settings', @@ -246,11 +259,21 @@ export const useDashboardMenuItems = ({ */ const isLabsEnabled = useMemo(() => coreServices.uiSettings.get(UI_SETTINGS.ENABLE_LABS_UI), []); + const hasExportIntegration = Boolean( + shareService?.availableIntegrations('dashboard', 'export')?.length + ); + const viewModeTopNavConfig = useMemo(() => { const { showWriteControls } = getDashboardCapabilities(); const labsMenuItem = isLabsEnabled ? [menuItems.labs] : []; - const shareMenuItem = shareService ? [menuItems.share] : []; + const shareMenuItem = shareService + ? ([ + // Only show the export button if the current user meets the requirements for at least one registered export integration + hasExportIntegration ? menuItems.export : null, + menuItems.share, + ].filter(Boolean) as TopNavMenuData[]) + : []; const duplicateMenuItem = showWriteControls ? [menuItems.interactiveSave] : []; const editMenuItem = showWriteControls && !dashboardApi.isManaged ? [menuItems.edit] : []; const mayberesetChangesMenuItem = showResetChange ? [resetChangesMenuItem] : []; @@ -258,16 +281,35 @@ export const useDashboardMenuItems = ({ return [ ...labsMenuItem, menuItems.fullScreen, - ...shareMenuItem, ...duplicateMenuItem, ...mayberesetChangesMenuItem, + ...shareMenuItem, ...editMenuItem, ]; - }, [isLabsEnabled, menuItems, dashboardApi.isManaged, showResetChange, resetChangesMenuItem]); + }, [ + isLabsEnabled, + menuItems.labs, + menuItems.export, + menuItems.share, + menuItems.interactiveSave, + menuItems.edit, + menuItems.fullScreen, + hasExportIntegration, + dashboardApi.isManaged, + showResetChange, + resetChangesMenuItem, + ]); const editModeTopNavConfig = useMemo(() => { const labsMenuItem = isLabsEnabled ? [menuItems.labs] : []; - const shareMenuItem = shareService ? [menuItems.share] : []; + const shareMenuItem = shareService + ? ([ + // Only show the export button if the current user meets the requirements for at least one registered export integration + hasExportIntegration ? menuItems.export : null, + menuItems.share, + ].filter(Boolean) as TopNavMenuData[]) + : []; + const editModeItems: TopNavMenuData[] = []; if (lastSavedId) { @@ -281,8 +323,27 @@ export const useDashboardMenuItems = ({ } else { editModeItems.push(menuItems.switchToViewMode, menuItems.interactiveSave); } - return [...labsMenuItem, menuItems.settings, ...shareMenuItem, ...editModeItems]; - }, [isLabsEnabled, menuItems, lastSavedId, showResetChange, resetChangesMenuItem]); + + const editModeTopNavConfigItems = [...labsMenuItem, menuItems.settings, ...editModeItems]; + + // insert share menu item before the last item in edit mode + editModeTopNavConfigItems.splice(-1, 0, ...shareMenuItem); + + return editModeTopNavConfigItems; + }, [ + isLabsEnabled, + menuItems.labs, + menuItems.export, + menuItems.share, + menuItems.settings, + menuItems.interactiveSave, + menuItems.switchToViewMode, + menuItems.quickSave, + hasExportIntegration, + lastSavedId, + showResetChange, + resetChangesMenuItem, + ]); return { viewModeTopNavConfig, editModeTopNavConfig }; }; 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 3ab1e86617ac7..4dfc5116406a5 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 @@ -7,6 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import React from 'react'; +import { EuiCallOut } from '@elastic/eui'; import type { AppMenuActionPrimary } from '@kbn/discover-utils'; import { AppMenuActionId, AppMenuActionType } from '@kbn/discover-utils'; import { omit } from 'lodash'; @@ -26,114 +28,175 @@ export const getShareAppMenuItem = ({ discoverParams: AppMenuDiscoverParams; services: DiscoverServices; stateContainer: DiscoverStateContainer; -}): AppMenuActionPrimary => { - return { - id: AppMenuActionId.share, - type: AppMenuActionType.primary, - controlProps: { - label: i18n.translate('discover.localMenu.shareTitle', { - defaultMessage: 'Share', - }), - description: i18n.translate('discover.localMenu.shareSearchDescription', { - defaultMessage: 'Share Discover session', - }), - iconType: 'share', - testId: 'shareTopNavButton', - onClick: async ({ anchorElement }) => { - const { dataView, isEsqlMode } = discoverParams; +}): AppMenuActionPrimary[] => { + if (!services.share) { + return []; + } - if (!services.share) { - return; - } + const shareExecutor = async ({ + anchorElement, + asExport, + }: { + anchorElement: HTMLElement; + asExport?: boolean; + }) => { + const { dataView, isEsqlMode } = discoverParams; - const savedSearch = stateContainer.savedSearchState.getState(); - const searchSourceSharingData = await getSharingData( - savedSearch.searchSource, - stateContainer.appState.getState(), - services, - isEsqlMode - ); + const savedSearch = stateContainer.savedSearchState.getState(); + const searchSourceSharingData = await getSharingData( + savedSearch.searchSource, + stateContainer.appState.getState(), + services, + isEsqlMode + ); - const { locator } = services; - const appState = stateContainer.appState.getState(); - const { timefilter } = services.data.query.timefilter; - const timeRange = timefilter.getTime(); - const refreshInterval = timefilter.getRefreshInterval(); - const filters = services.filterManager.getFilters(); + const { locator } = services; + const appState = stateContainer.appState.getState(); + const { timefilter } = services.data.query.timefilter; + const timeRange = timefilter.getTime(); + const refreshInterval = timefilter.getRefreshInterval(); + const filters = services.filterManager.getFilters(); - // Share -> Get links -> Snapshot - const params: DiscoverAppLocatorParams = { - ...omit(appState, 'dataSource'), - ...(savedSearch.id ? { savedSearchId: savedSearch.id } : {}), - ...(dataView?.isPersisted() - ? { dataViewId: dataView?.id } - : { dataViewSpec: dataView?.toMinimalSpec() }), - filters, - timeRange, - refreshInterval, - }; - const relativeUrl = locator.getRedirectUrl(params); + // Share -> Get links -> Snapshot + const params: DiscoverAppLocatorParams = { + ...omit(appState, 'dataSource'), + ...(savedSearch.id ? { savedSearchId: savedSearch.id } : {}), + ...(dataView?.isPersisted() + ? { dataViewId: dataView?.id } + : { dataViewSpec: dataView?.toMinimalSpec() }), + filters, + timeRange, + refreshInterval, + }; + const relativeUrl = locator.getRedirectUrl(params); - // This logic is duplicated from `relativeToAbsolute` (for bundle size reasons). Ultimately, this should be - // replaced when https://github.com/elastic/kibana/issues/153323 is implemented. - const link = document.createElement('a'); - link.setAttribute('href', relativeUrl); - const shareableUrl = link.href; + // This logic is duplicated from `relativeToAbsolute` (for bundle size reasons). Ultimately, this should be + // replaced when https://github.com/elastic/kibana/issues/153323 is implemented. + const link = document.createElement('a'); + link.setAttribute('href', relativeUrl); + const shareableUrl = link.href; - // Share -> Get links -> Saved object - let shareableUrlForSavedObject = await locator.getUrl( - { savedSearchId: savedSearch.id }, - { absolute: true } - ); + // Share -> Get links -> Saved object + let shareableUrlForSavedObject = await locator.getUrl( + { savedSearchId: savedSearch.id }, + { absolute: true } + ); - // UrlPanelContent forces a '_g' parameter in the saved object URL: - // https://github.com/elastic/kibana/blob/a30508153c1467b1968fb94faf1debc5407f61ea/src/plugins/share/public/components/url_panel_content.tsx#L230 - // Since our locator doesn't add the '_g' parameter if it's not needed, UrlPanelContent - // will interpret it as undefined and add '?_g=' to the URL, which is invalid in Discover, - // so instead we add an empty object for the '_g' parameter to the URL. - shareableUrlForSavedObject = setStateToKbnUrl( - '_g', - {}, - undefined, - shareableUrlForSavedObject - ); + // UrlPanelContent forces a '_g' parameter in the saved object URL: + // https://github.com/elastic/kibana/blob/a30508153c1467b1968fb94faf1debc5407f61ea/src/plugins/share/public/components/url_panel_content.tsx#L230 + // Since our locator doesn't add the '_g' parameter if it's not needed, UrlPanelContent + // will interpret it as undefined and add '?_g=' to the URL, which is invalid in Discover, + // so instead we add an empty object for the '_g' parameter to the URL. + shareableUrlForSavedObject = setStateToKbnUrl('_g', {}, undefined, shareableUrlForSavedObject); - services.share.toggleShareContextMenu({ - anchorElement, - allowShortUrl: !!services.capabilities.discover.createShortUrl, - shareableUrl, - shareableUrlForSavedObject, - shareableUrlLocatorParams: { locator, params }, - objectId: savedSearch.id, - objectType: 'search', - objectTypeMeta: { - title: i18n.translate('discover.share.shareModal.title', { - defaultMessage: 'Share this Discover session', - }), - config: { - embed: { - disabled: true, - showPublicUrlSwitch, + services.share?.toggleShareContextMenu({ + asExport, + anchorElement, + allowShortUrl: !!services.capabilities.discover.createShortUrl, + shareableUrl, + shareableUrlForSavedObject, + shareableUrlLocatorParams: { locator, params }, + objectId: savedSearch.id, + objectType: 'search', + objectTypeAlias: i18n.translate('discover.share.objectTypeAlias', { + defaultMessage: 'Discover session', + }), + objectTypeMeta: { + title: i18n.translate('discover.share.shareModal.title', { + defaultMessage: 'Share this Discover session', + }), + config: { + embed: { + disabled: true, + showPublicUrlSwitch, + }, + integration: { + export: { + csvReports: { + draftModeCallOut: ( + + {i18n.translate( + 'discover.exports.csvReports.postURLWatcherMessage.unsavedChanges', + { + defaultMessage: 'URL may change if you upgrade Kibana.', + } + )} + + ), }, }, }, - sharingData: { - isTextBased: isEsqlMode, - locatorParams: [{ id: locator.id, params }], - ...searchSourceSharingData, - // CSV reports can be generated without a saved search so we provide a fallback title - title: - savedSearch.title || - i18n.translate('discover.localMenu.fallbackReportTitle', { - defaultMessage: 'Untitled Discover session', - }), - }, - isDirty: !savedSearch.id || stateContainer.appState.hasChanged(), - onClose: () => { - anchorElement?.focus(); + }, + }, + sharingData: { + isTextBased: isEsqlMode, + locatorParams: [ + { + id: locator.id, + params: isEsqlMode + ? { + ...params, + timeRange: timefilter.getAbsoluteTime(), // Will be used when generating CSV on server. See `filtersFromLocator`. + } + : params, }, - }); + ], + ...searchSourceSharingData, + // CSV reports can be generated without a saved search so we provide a fallback title + title: + savedSearch.title || + i18n.translate('discover.localMenu.fallbackReportTitle', { + defaultMessage: 'Untitled Discover session', + }), }, - }, + isDirty: !savedSearch.id || stateContainer.appState.hasChanged(), + onClose: () => { + anchorElement?.focus(); + }, + }); }; + + const menuItems: AppMenuActionPrimary[] = [ + { + id: AppMenuActionId.share, + type: AppMenuActionType.primary, + controlProps: { + label: i18n.translate('discover.localMenu.shareTitle', { + defaultMessage: 'Share', + }), + description: i18n.translate('discover.localMenu.shareSearchDescription', { + defaultMessage: 'Share Discover session', + }), + iconType: 'share', + testId: 'shareTopNavButton', + onClick: ({ anchorElement }) => shareExecutor({ anchorElement }), + }, + }, + ]; + + if (Boolean(services.share?.availableIntegrations('search', 'export')?.length)) { + menuItems.unshift({ + id: AppMenuActionId.export, + type: AppMenuActionType.primary, + controlProps: { + label: i18n.translate('discover.localMenu.exportTitle', { + defaultMessage: 'Export', + }), + description: i18n.translate('discover.localMenu.shareSearchDescription', { + defaultMessage: 'Export Discover session', + }), + iconType: 'download', + testId: 'exportTopNavButton', + onClick: ({ anchorElement }) => shareExecutor({ anchorElement, asExport: true }), + }, + }); + } + + return menuItems; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx index 222efb6c66ed2..2aed9ef0db3a1 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx @@ -16,6 +16,7 @@ import type { DiscoverTopNavProps } from './discover_topnav'; import { DiscoverTopNav } from './discover_topnav'; import type { TopNavMenuData } from '@kbn/navigation-plugin/public'; import { TopNavMenu } from '@kbn/navigation-plugin/public'; +import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; import { discoverServiceMock as mockDiscoverService } from '../../../../__mocks__/services'; import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; import { DiscoverMainProvider } from '../../state_management/discover_state_provider'; @@ -126,7 +127,7 @@ describe('Discover topnav component', () => { const component = getTestComponent(props); const topNavMenu = component.find(TopNavMenu); const topMenuConfig = topNavMenu.props().config?.map((obj: TopNavMenuData) => obj.id); - expect(topMenuConfig).toEqual(['inspect', 'new', 'open', 'share', 'save']); + expect(topMenuConfig).toEqual(['inspect', 'new', 'open', 'save']); }); test('generated config of TopNavMenu config is correct when no discover save permissions are assigned', () => { @@ -135,7 +136,7 @@ describe('Discover topnav component', () => { const topNavMenu = component.find(TopNavMenu).props(); const topMenuConfig = topNavMenu.config?.map((obj: TopNavMenuData) => obj.id); - expect(topMenuConfig).toEqual(['inspect', 'new', 'open', 'share']); + expect(topMenuConfig).toEqual(['inspect', 'new', 'open']); }); test('top nav is correct when discover saveQuery permission is granted', () => { @@ -176,6 +177,53 @@ describe('Discover topnav component', () => { const topMenuConfig = topNavMenu.props().config?.map((obj: TopNavMenuData) => obj.id); expect(topMenuConfig).toEqual([]); }); + + describe('share service available', () => { + beforeAll(() => { + mockDiscoverService.share = sharePluginMock.createStartContract(); + }); + + afterAll(() => { + mockDiscoverService.share = undefined; + }); + + it('will include share menu item if the share service is available', () => { + const props = getProps(); + const component = getTestComponent(props); + const topNavMenu = component.find(TopNavMenu); + const topMenuConfig = topNavMenu.props().config?.map((obj: TopNavMenuData) => obj.id); + expect(topMenuConfig).toEqual(['inspect', 'new', 'open', 'share', 'save']); + }); + + it('will include export menu item if there are export integrations available', () => { + const availableIntegrationsSpy = jest.spyOn( + mockDiscoverService.share!, + 'availableIntegrations' + ); + + availableIntegrationsSpy.mockImplementation((_objectType, groupId) => { + if (groupId === 'export') { + return [ + { + id: 'export', + shareType: 'integration', + groupId: 'export', + config: () => ({}), + }, + ]; + } + + return []; + }); + + const props = getProps(); + + const component = getTestComponent(props); + const topNavMenu = component.find(TopNavMenu).props(); + const topMenuConfig = topNavMenu.config?.map((obj: TopNavMenuData) => obj.id); + expect(topMenuConfig).toEqual(['inspect', 'new', 'open', 'export', 'share', 'save']); + }); + }); }); describe('search bar customization', () => { diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx index 7c9c2312a4f28..3530f172f5965 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { renderHook } from '@testing-library/react'; +import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { useTopNavLinks } from './use_top_nav_links'; import type { DiscoverServices } from '../../../../build_services'; @@ -98,15 +99,6 @@ describe('useTopNavLinks', () => { "run": [Function], "testId": "discoverOpenButton", }, - Object { - "description": "Share Discover session", - "iconOnly": true, - "iconType": "share", - "id": "share", - "label": "Share", - "run": [Function], - "testId": "shareTopNavButton", - }, Object { "description": "Save session", "emphasize": true, @@ -174,15 +166,6 @@ describe('useTopNavLinks', () => { "run": [Function], "testId": "discoverOpenButton", }, - Object { - "description": "Share Discover session", - "iconOnly": true, - "iconType": "share", - "id": "share", - "label": "Share", - "run": [Function], - "testId": "shareTopNavButton", - }, Object { "description": "Save session", "emphasize": true, @@ -195,4 +178,127 @@ describe('useTopNavLinks', () => { ] `); }); + + describe('useTopNavLinks with share service included', () => { + beforeAll(() => { + services.share = sharePluginMock.createStartContract(); + }); + + afterAll(() => { + services.share = undefined; + }); + + it('will include share menu item if the share service is available', () => { + const topNavLinks = renderHook( + () => + useTopNavLinks({ + dataView: dataViewMock, + onOpenInspector: jest.fn(), + services, + state, + isEsqlMode: false, + adHocDataViews: [], + topNavCustomization: undefined, + shouldShowESQLToDataViewTransitionModal: false, + }), + { + wrapper: Wrapper, + } + ).result.current; + expect(topNavLinks).toMatchInlineSnapshot(` + Array [ + Object { + "color": "text", + "emphasize": true, + "fill": false, + "id": "esql", + "label": "Try ES|QL", + "run": [Function], + "testId": "select-text-based-language-btn", + "tooltip": "ES|QL is Elastic's powerful new piped query language.", + }, + Object { + "description": "Open Inspector for search", + "id": "inspect", + "label": "Inspect", + "run": [Function], + "testId": "openInspectorButton", + }, + Object { + "description": "New session", + "iconOnly": true, + "iconType": "plus", + "id": "new", + "label": "New session", + "run": [Function], + "testId": "discoverNewButton", + }, + Object { + "description": "Open session", + "iconOnly": true, + "iconType": "folderOpen", + "id": "open", + "label": "Open session", + "run": [Function], + "testId": "discoverOpenButton", + }, + Object { + "description": "Share Discover session", + "iconOnly": true, + "iconType": "share", + "id": "share", + "label": "Share", + "run": [Function], + "testId": "shareTopNavButton", + }, + Object { + "description": "Save session", + "emphasize": true, + "iconType": "save", + "id": "save", + "label": "Save", + "run": [Function], + "testId": "discoverSaveButton", + }, + ] + `); + }); + + it('will include export menu item if there are export integrations available', () => { + const availableIntegrationsSpy = jest.spyOn(services.share!, 'availableIntegrations'); + + availableIntegrationsSpy.mockImplementation((_objectType, groupId) => { + if (groupId === 'export') { + return [ + { + id: 'export', + shareType: 'integration', + groupId: 'export', + config: () => ({}), + }, + ]; + } + + return []; + }); + + const topNavLinks = renderHook( + () => + useTopNavLinks({ + dataView: dataViewMock, + onOpenInspector: jest.fn(), + services, + state, + isEsqlMode: false, + adHocDataViews: [], + topNavCustomization: undefined, + shouldShowESQLToDataViewTransitionModal: false, + }), + { + wrapper: Wrapper, + } + ).result.current; + expect(topNavLinks.filter((obj) => obj.id === 'export')).toBeDefined(); + }); + }); }); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index 0c2eee98b1d8e..4ca20502ad713 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -142,7 +142,7 @@ export const useTopNavLinks = ({ services, stateContainer: state, }); - items.push(shareAppMenuItem); + items.push(...shareAppMenuItem); } return items; diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_item.tsx index e09dc70b1b4fa..9c3275775662c 100644 --- a/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -83,7 +83,18 @@ export function TopNavMenuItem(props: TopNavMenuItemProps) { const btn = props.iconOnly && props.iconType && !props.isMobileMenu ? ( // icon only buttons are not supported by EuiHeaderLink - + React.createElement( + props.disableButton ? React.Fragment : EuiToolTip, + // @ts-expect-error - EuiToolTip does not accept `key` prop, we pass to react Fragment + { + ...(props.disableButton + ? { key: props.label || props.id! } + : { + content: upperFirst(props.label || props.id!), + position: 'bottom', + delay: 'long', + }), + }, - + ) ) : props.emphasize ? ( // fill is not compatible with EuiHeaderLink item.shareType === shareType && item?.groupId === groupId); + type ObjectTypeMetaConfig = IShareContext['objectTypeMeta']['config']; + + const shareTypeObjectMeta: Omit & { + config: T extends 'integration' + ? NonNullable['integration']>[G] | undefined + : Exclude, 'integration'>[T]; + } = { + ...objectTypeMeta, + // @ts-expect-error -- this is a workaround for the type system + config: + shareType === 'integration' + ? groupId + ? objectTypeMeta.config?.integration?.[groupId] + : {} + : objectTypeMeta.config?.[shareType], + }; + return { ...rest, - objectTypeMeta: { - ...objectTypeMeta, - config: objectTypeMeta.config[shareType], - }, + objectTypeMeta: shareTypeObjectMeta, shareMenuItems: shareTypeImplementations, }; }; diff --git a/src/platform/plugins/shared/share/public/components/export_popover/export_popover.test.tsx b/src/platform/plugins/shared/share/public/components/export_popover/export_popover.test.tsx new file mode 100644 index 0000000000000..9e6c968a78b4c --- /dev/null +++ b/src/platform/plugins/shared/share/public/components/export_popover/export_popover.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; +import { ExportMenu } from './export_popover'; +import type { IShareContext } from '../context'; + +const mockShareContext: IShareContext = { + shareMenuItems: [ + { + shareType: 'integration', + groupId: 'export', + id: 'csv', + config: { + icon: 'empty', + label: 'CSV', + }, + }, + { + shareType: 'integration', + groupId: 'export', + id: 'png', + config: { + icon: 'empty', + label: 'PNG', + }, + }, + ], + allowShortUrl: true, + objectTypeMeta: { + title: 'title', + config: { + embed: { + disabled: false, + }, + }, + }, + objectType: 'type', + sharingData: { title: 'title', url: 'url' }, + isDirty: false, + onClose: jest.fn(), +}; + +function ExportPopoverRender() { + const [clickTarget, setClickTarget] = React.useState(); + + return ( + + {Boolean(clickTarget) && ( + + )} +
click me
+
+ ); +} + +describe('ExportPopover', () => { + it('renders a popover with the list of registered export types', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByText('click me')); + + await waitForEuiPopoverOpen(); + + ['CSV', 'PNG'].forEach((label) => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/platform/plugins/shared/share/public/components/export_popover/export_popover.tsx b/src/platform/plugins/shared/share/public/components/export_popover/export_popover.tsx new file mode 100644 index 0000000000000..7f6c0bef3dbba --- /dev/null +++ b/src/platform/plugins/shared/share/public/components/export_popover/export_popover.tsx @@ -0,0 +1,302 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { type FC, useState, Fragment, useMemo, useCallback, useEffect } from 'react'; +import { + EuiWrappingPopover, + EuiListGroup, + EuiListGroupItem, + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFormRow, + EuiText, + EuiTitle, + EuiButton, + EuiButtonEmpty, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + type EuiSwitchEvent, + EuiSwitch, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n-react'; +import { ShareMenuProvider, type IShareContext, useShareTabsContext } from '../context'; +import { ExportShareConfig } from '../../types'; + +export const ExportMenu: FC<{ shareContext: IShareContext }> = ({ shareContext }) => { + return ( + + {React.createElement(injectI18n(ExportMenuPopover))} + + ); +}; + +interface ExportMenuProps { + intl: InjectedIntl; +} + +interface LayoutOptionsProps { + usePrintLayout: boolean; + printLayoutChange: (evt: EuiSwitchEvent) => void; +} + +function LayoutOptionsSwitch({ usePrintLayout, printLayoutChange }: LayoutOptionsProps) { + return ( + + + +

+ +

+ + } + helpText={ + + + + } + fullWidth + > + + {usePrintLayout ? ( + + ) : ( + + )} + + } + checked={usePrintLayout} + onChange={printLayoutChange} + data-test-subj="usePrintLayout" + /> +
+
+
+ ); +} + +function ExportMenuPopover({ intl }: ExportMenuProps) { + const { + onClose, + anchorElement, + shareMenuItems, + isDirty, + publicAPIEnabled, + objectType, + objectTypeAlias, + objectTypeMeta, + } = useShareTabsContext('integration', 'export'); + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const [isCreatingExport, setIsCreatingExport] = useState(false); + const [selectedMenuItemId, setSelectedMenuItemId] = useState(); + const selectedMenuItem = useMemo(() => { + return shareMenuItems.find((item) => item.id === selectedMenuItemId) ?? null; + }, [shareMenuItems, selectedMenuItemId]); + const [usePrintLayout, setPrintLayout] = useState(false); + + const getReport = useCallback(async () => { + try { + setIsCreatingExport(true); + await selectedMenuItem?.config.generateAssetExport({ + intl, + optimizedForPrinting: usePrintLayout, + }); + } finally { + setIsCreatingExport(false); + onClose?.(); + } + }, [intl, onClose, selectedMenuItem?.config, usePrintLayout]); + + useEffect(() => { + // when there is only one share menu item, + // we want to open the flyout and not the popover + if (shareMenuItems.length === 1) { + setSelectedMenuItemId(shareMenuItems[0].id); + setIsFlyoutVisible(true); + } + }, [shareMenuItems]); + + const flyoutOnCloseHandler = useCallback(() => { + return shareMenuItems.length === 1 ? onClose() : setIsFlyoutVisible(false); + }, [onClose, shareMenuItems.length]); + + const DraftModeCallout = objectTypeMeta.config?.[selectedMenuItemId!]?.draftModeCallOut; + + return ( + + + + {shareMenuItems.map((menuItem) => ( + + { + setSelectedMenuItemId(menuItem.id); + setIsFlyoutVisible(true); + }} + /> + + ))} + + + {isFlyoutVisible && ( + ({ + ['--euiFixedHeadersOffset']: 0, + })} + ownFocus + maskProps={{ + headerZindexLocation: 'above', + }} + > + + +

+ +

+
+
+ + + + {selectedMenuItem?.config.renderLayoutOptionSwitch && ( + + setPrintLayout(evt.target.checked)} + /> + + )} + + + {selectedMenuItem?.config.copyAssetURIConfig && publicAPIEnabled && ( + + +

{selectedMenuItem?.config.copyAssetURIConfig.headingText}

+ + } + fullWidth + > + + + + {selectedMenuItem?.config.copyAssetURIConfig.helpText} + + + + + {selectedMenuItem?.config.copyAssetURIConfig.generateAssetURIValue({ + intl, + optimizedForPrinting: usePrintLayout, + })} + + + +
+
+ )} +
+ {selectedMenuItem?.config.generateAssetComponent} + + {publicAPIEnabled && isDirty && DraftModeCallout && ( + {DraftModeCallout} + )} + +
+
+ + + + + + + + + + {selectedMenuItem?.config.generateExportButtonLabel ?? ( + + )} + + + + +
+ )} +
+ ); +} diff --git a/src/platform/plugins/shared/share/public/components/export_popover/index.ts b/src/platform/plugins/shared/share/public/components/export_popover/index.ts new file mode 100644 index 0000000000000..cd22aaf0f3133 --- /dev/null +++ b/src/platform/plugins/shared/share/public/components/export_popover/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 { ExportMenu } from './export_popover'; 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 03bb67317691f..ba7e838f9dc01 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 @@ -10,7 +10,8 @@ import React from 'react'; import { ShareMenuTabs } from './share_tabs'; import { ShareMenuProvider, type IShareContext } from './context'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { screen, render } from '@testing-library/react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { KibanaLocation, LocatorGetUrlParams, UrlService } from '../../common/url_service'; import { BrowserShortUrlClient, @@ -61,6 +62,15 @@ const mockShareContext: IShareContext = { anonymousAccess: { getCapabilities: jest.fn(), getState: jest.fn() }, }, }, + { + shareType: 'integration', + groupId: 'export', + id: 'csv', + config: { + icon: 'empty', + label: 'CSV', + }, + }, ], allowShortUrl: true, objectTypeMeta: { @@ -77,12 +87,18 @@ const mockShareContext: IShareContext = { onClose: jest.fn(), }; -const mockGenerateExport = jest.fn(); -const mockGenerateExportUrl = jest.fn().mockImplementation(() => 'generated-export-url'); -const CSV = 'CSV' as const; -const PNG = 'PNG' as const; - describe('Share modal tabs', () => { + it('does not render an export tab', () => { + render( + + + + + + ); + expect(screen.queryByTestId('export')).not.toBeInTheDocument(); + }); + describe('link tab', () => { it('should not render the link tab when it is configured as disabled', async () => { const disabledLinkShareContext = { @@ -98,111 +114,14 @@ describe('Share modal tabs', () => { }, }; - const wrapper = mountWithIntl( - - - - ); - expect(wrapper.find('[data-test-subj="link"]').exists()).toBeFalsy(); - }); - }); - - describe('export tab', () => { - it('should render export tab when there are share menu items that are not disabled', async () => { - const shareContextWithConfiguredExportItem: IShareContext = { - ...mockShareContext, - shareMenuItems: [ - ...mockShareContext.shareMenuItems, - { - id: 'test-export', - shareType: 'integration', - groupId: 'export', - config: { - name: 'test', - disabled: false, - label: CSV, - generateExport: mockGenerateExport, - generateExportUrl: mockGenerateExportUrl, - }, - }, - ], - }; - - const wrapper = mountWithIntl( - - - - ); - expect(wrapper.find('[data-test-subj="export"]').exists()).toBeTruthy(); - }); - - it('should not render export tab when it has only one item configured as disabled', async () => { - const shareContextWithConfiguredExportItem: IShareContext = { - ...mockShareContext, - shareMenuItems: [ - ...mockShareContext.shareMenuItems, - { - id: 'test-export', - shareType: 'integration', - groupId: 'export', - config: { - name: 'test', - disabled: true, - label: CSV, - generateExport: mockGenerateExport, - generateExportUrl: mockGenerateExportUrl, - }, - }, - ], - }; - - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="export"]').exists()).toBeFalsy(); - }); - - it('would render the export tab when there is at least one export type which is not disabled', async () => { - const shareContextWithConfiguredExportItem: IShareContext = { - ...mockShareContext, - shareMenuItems: [ - ...mockShareContext.shareMenuItems, - { - id: 'test-csv-export', - shareType: 'integration', - groupId: 'export', - config: { - name: 'test', - disabled: false, - label: CSV, - generateExport: mockGenerateExport, - generateExportUrl: mockGenerateExportUrl, - }, - }, - { - id: 'test-png-export', - shareType: 'integration', - groupId: 'export', - config: { - name: 'test', - disabled: true, - label: PNG, - generateExport: mockGenerateExport, - generateExportUrl: mockGenerateExportUrl, - }, - }, - ], - }; - - const wrapper = mountWithIntl( - - - + render( + + + + + ); - expect(wrapper.find('[data-test-subj="export"]').exists()).toBeTruthy(); + expect(screen.queryByTestId('link')).not.toBeInTheDocument(); }); }); }); diff --git a/src/platform/plugins/shared/share/public/components/share_tabs.tsx b/src/platform/plugins/shared/share/public/components/share_tabs.tsx index 0935fd9d80e0f..8ae07e2a09470 100644 --- a/src/platform/plugins/shared/share/public/components/share_tabs.tsx +++ b/src/platform/plugins/shared/share/public/components/share_tabs.tsx @@ -11,7 +11,7 @@ import React, { type FC } from 'react'; import { TabbedModal, type IModalTabDeclaration } from '@kbn/shared-ux-tabbed-modal'; import { ShareMenuProvider, useShareContext, type IShareContext } from './context'; -import { linkTab, embedTab, exportTab } from './tabs'; +import { linkTab, embedTab } from './tabs'; export const ShareMenu: FC<{ shareContext: IShareContext }> = ({ shareContext }) => { return ( @@ -34,18 +34,6 @@ export const ShareMenuTabs = () => { tabs.push(linkTab); } - // Do not show the export tab if there's no export type enabled - if ( - shareMenuItems.some( - (shareItem) => - shareItem.shareType === 'integration' && - shareItem.groupId === 'export' && - !shareItem.config.disabled - ) - ) { - tabs.push(exportTab); - } - // Embed is disabled in the serverless offering, hence the need to check that we received it if ( shareMenuItems.some(({ shareType }) => shareType === 'embed') && diff --git a/src/platform/plugins/shared/share/public/components/tabs/export/export_content.tsx b/src/platform/plugins/shared/share/public/components/tabs/export/export_content.tsx deleted file mode 100644 index 73e330663ed1c..0000000000000 --- a/src/platform/plugins/shared/share/public/components/tabs/export/export_content.tsx +++ /dev/null @@ -1,269 +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 React, { useCallback, useState, useMemo } from 'react'; -import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n-react'; - -import { - EuiButton, - EuiButtonEmpty, - EuiCallOut, - EuiCopy, - EuiFlexGroup, - EuiFlexItem, - EuiForm, - EuiRadioGroup, - EuiSpacer, - EuiSwitch, - EuiSwitchEvent, - EuiText, - EuiIconTip, - type EuiRadioGroupOption, -} from '@elastic/eui'; -import { type IShareContext } from '../../context'; -import { ExportShareConfig } from '../../../types'; - -type ExportProps = Pick & { - layoutOption?: 'print'; - aggregateExportTypes: ExportShareConfig[]; - intl: InjectedIntl; - publicAPIEnabled: boolean; -}; - -const ExportContentUi = ({ - isDirty, - aggregateExportTypes, - intl, - onClose, - publicAPIEnabled, -}: ExportProps) => { - const [isCreatingExport, setIsCreatingExport] = useState(false); - const [usePrintLayout, setPrintLayout] = useState(false); - - const radioOptions = useMemo(() => { - return aggregateExportTypes.reduce((acc, { id, config }) => { - acc.push({ - id: config.exportType, - label: config.label, - 'data-test-subj': `${config.exportType}-radioOption`, - }); - - return acc; - }, []); - }, [aggregateExportTypes]); - - const [selectedRadio, setSelectedRadio] = useState(radioOptions[0].id); - - const { - config: { - generateExportButton, - helpText, - warnings = [], - renderCopyURIButton: renderCopyURLButton, - generateAssetExport: generateExport, - generateAssetURIValue: generateExportUrl, - renderLayoutOptionSwitch, - }, - } = useMemo(() => { - return aggregateExportTypes?.find(({ config }) => config.exportType === selectedRadio)!; - }, [selectedRadio, aggregateExportTypes]); - - const handlePrintLayoutChange = useCallback( - (evt: EuiSwitchEvent) => { - setPrintLayout(evt.target.checked); - }, - [setPrintLayout] - ); - - const getReport = useCallback(async () => { - try { - setIsCreatingExport(true); - await generateExport({ intl, optimizedForPrinting: usePrintLayout }); - } finally { - setIsCreatingExport(false); - onClose?.(); - } - }, [generateExport, intl, usePrintLayout, onClose]); - - const renderLayoutOptionsSwitch = useCallback(() => { - if (renderLayoutOptionSwitch) { - return ( - - - - - - } - checked={usePrintLayout} - onChange={handlePrintLayoutChange} - data-test-subj="usePrintLayout" - /> - - - - } - /> - - - ); - } - }, [usePrintLayout, renderLayoutOptionSwitch, handlePrintLayoutChange]); - - const showCopyURLButton = useCallback(() => { - if (renderCopyURLButton && publicAPIEnabled) { - const absoluteUrl = generateExportUrl?.({ intl, optimizedForPrinting: usePrintLayout }); - return ( - - - - {(copy) => ( - - - - )} - - - - - - - } - /> - - - ); - } - }, [renderCopyURLButton, publicAPIEnabled, usePrintLayout, generateExportUrl, intl]); - - const renderGenerateReportButton = useCallback(() => { - return ( - 0 ? 'warning' : 'primary'} - onClick={getReport} - data-test-subj="generateReportButton" - isLoading={isCreatingExport} - > - {generateExportButton ?? ( - - )} - - ); - }, [ - generateExportButton, - getReport, - isCreatingExport, - isDirty, - renderCopyURLButton, - warnings.length, - ]); - - const renderRadioOptions = () => { - if (radioOptions.length > 1) { - return ( - <> - - - setSelectedRadio(id)} - name="image reporting radio group" - idSelected={selectedRadio} - legend={{ - children: , - }} - /> - - - ); - } - }; - - const renderDirtyWarning = () => { - return ( - renderCopyURLButton && - publicAPIEnabled && - isDirty && ( - <> - - - } - > - - - - ) - ); - }; - - const renderWarnings = (warning: { title: string; message: string }) => ( - <> - - - {warning.message} - - - ); - - return ( - <> - - - {helpText ?? ( - - )} - - {renderRadioOptions()} - {renderDirtyWarning()} - {warnings.map(renderWarnings)} - - - - {renderLayoutOptionsSwitch()} - {showCopyURLButton()} - {renderGenerateReportButton()} - - - ); -}; - -export const ExportContent = injectI18n(ExportContentUi); diff --git a/src/platform/plugins/shared/share/public/components/tabs/export/index.tsx b/src/platform/plugins/shared/share/public/components/tabs/export/index.tsx deleted file mode 100644 index d4a891a060d4f..0000000000000 --- a/src/platform/plugins/shared/share/public/components/tabs/export/index.tsx +++ /dev/null @@ -1,41 +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 { i18n } from '@kbn/i18n'; -import React from 'react'; -import { type IModalTabDeclaration } from '@kbn/shared-ux-tabbed-modal'; -import { ExportContent } from './export_content'; -import { useShareTabsContext } from '../../context'; - -type IExportTab = IModalTabDeclaration; - -const ExportTabContent = () => { - const { shareMenuItems, objectType, isDirty, onClose, publicAPIEnabled } = useShareTabsContext( - 'integration', - 'export' - ); - - return ( - - ); -}; - -export const exportTab: IExportTab = { - id: 'export', - name: i18n.translate('share.contextMenu.exportCodeTab', { - defaultMessage: 'Export', - }), - content: ExportTabContent, -}; diff --git a/src/platform/plugins/shared/share/public/components/tabs/index.ts b/src/platform/plugins/shared/share/public/components/tabs/index.ts index 4ff2dc3014c99..7a5670d35cd2c 100644 --- a/src/platform/plugins/shared/share/public/components/tabs/index.ts +++ b/src/platform/plugins/shared/share/public/components/tabs/index.ts @@ -9,4 +9,3 @@ export { linkTab } from './link'; export { embedTab } from './embed'; -export { exportTab } from './export'; diff --git a/src/platform/plugins/shared/share/public/mocks.ts b/src/platform/plugins/shared/share/public/mocks.ts index 9ee2cd787a8fd..69b6d8e3f394a 100644 --- a/src/platform/plugins/shared/share/public/mocks.ts +++ b/src/platform/plugins/shared/share/public/mocks.ts @@ -52,6 +52,7 @@ const createStartContract = (): Start => { const startContract: Start = { url, toggleShareContextMenu: jest.fn(), + availableIntegrations: jest.fn(), navigate: jest.fn(), }; return startContract; diff --git a/src/platform/plugins/shared/share/public/plugin.test.ts b/src/platform/plugins/shared/share/public/plugin.test.ts index 5a409ea8c5f99..414d390cd1e72 100644 --- a/src/platform/plugins/shared/share/public/plugin.test.ts +++ b/src/platform/plugins/shared/share/public/plugin.test.ts @@ -9,10 +9,12 @@ import { registryMock, managerMock } from './plugin.test.mocks'; import { SharePlugin } from './plugin'; -import { CoreStart } from '@kbn/core/public'; import { coreMock } from '@kbn/core/public/mocks'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { anonymousAccessMock } from '../common/anonymous_access/index.mock'; +const licensingStartMock = licensingMock.createStart(); + describe('SharePlugin', () => { beforeEach(() => { managerMock.start.mockClear(); @@ -63,7 +65,9 @@ describe('SharePlugin', () => { }) ); await service.setup(coreSetup); - const start = await service.start({} as CoreStart); + const start = await service.start(coreMock.createStart(), { + licensing: licensingStartMock, + }); expect(registryMock.start).toHaveBeenCalled(); expect(managerMock.start).toHaveBeenCalledWith({ resolveShareItemsForShareContext: expect.any(Function), @@ -85,7 +89,9 @@ describe('SharePlugin', () => { const setup = await service.setup(coreSetup); const anonymousAccessServiceProvider = () => anonymousAccessMock.create(); setup.setAnonymousAccessServiceProvider(anonymousAccessServiceProvider); - const start = await service.start({} as CoreStart); + const start = await service.start(coreMock.createStart(), { + licensing: licensingStartMock, + }); expect(registryMock.start).toHaveBeenCalled(); expect(managerMock.start).toHaveBeenCalledWith({ resolveShareItemsForShareContext: expect.any(Function), diff --git a/src/platform/plugins/shared/share/public/plugin.ts b/src/platform/plugins/shared/share/public/plugin.ts index 820d94ec37d14..8f2595ab190bc 100644 --- a/src/platform/plugins/shared/share/public/plugin.ts +++ b/src/platform/plugins/shared/share/public/plugin.ts @@ -10,6 +10,8 @@ import './index.scss'; import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; +import type { Subscription } from 'rxjs'; +import type { ILicense, LicensingPluginStart } from '@kbn/licensing-plugin/public'; import { ShareMenuManager, ShareMenuManagerStart } from './services'; import { ShareRegistry, ShareMenuRegistrySetup } from './services'; import { UrlService } from '../common/url_service'; @@ -57,6 +59,11 @@ export type SharePublicStart = ShareMenuManagerStart & { * the locator, then using the locator to navigate. */ navigate(options: RedirectOptions): void; + + /** + * method to get all available integrations + */ + availableIntegrations: ShareRegistry['availableIntegrations']; }; // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -79,6 +86,7 @@ export class SharePlugin private redirectManager?: RedirectManager; private url?: BrowserUrlService; private anonymousAccessServiceProvider?: () => AnonymousAccessServiceContract; + private licenseSubscription?: Subscription; constructor(private readonly initializerContext: PluginInitializerContext) {} @@ -136,12 +144,23 @@ export class SharePlugin }; } - public start(core: CoreStart): SharePublicStart { + public start( + core: CoreStart, + { licensing }: { licensing?: LicensingPluginStart } + ): SharePublicStart { const isServerless = this.initializerContext.env.packageInfo.buildFlavor === 'serverless'; - const { resolveShareItemsForShareContext } = this.shareRegistry.start({ + let license: ILicense | undefined; + + this.licenseSubscription = licensing?.license$?.subscribe((_license) => { + license = _license; + }); + + const { resolveShareItemsForShareContext, availableIntegrations } = this.shareRegistry.start({ urlService: this.url!, anonymousAccessServiceProvider: () => this.anonymousAccessServiceProvider!(), + capabilities: core.application.capabilities, + getLicense: () => license, }); const sharingContextMenuStart = this.shareContextMenu.start({ @@ -154,6 +173,11 @@ export class SharePlugin ...sharingContextMenuStart, url: this.url!, navigate: (options: RedirectOptions) => this.redirectManager!.navigate(options), + availableIntegrations, }; } + + public stop() { + this.licenseSubscription?.unsubscribe(); + } } 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 73bb9010b7b9a..6e7e6a8843ddb 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 @@ -16,6 +16,7 @@ import { ShowShareMenuOptions } from '../types'; import { ShareRegistry } from './share_menu_registry'; import type { ShareConfigs } from '../types'; import { ShareMenu } from '../components/share_tabs'; +import { ExportMenu } from '../components/export_popover'; interface ShareMenuManagerStartDeps { core: CoreStart; @@ -78,6 +79,7 @@ export class ShareMenuManager { shareableUrlLocatorParams, onClose, isDirty, + asExport, publicAPIEnabled, }: ShowShareMenuOptions & { menuItems: ShareConfigs[]; @@ -97,8 +99,8 @@ export class ShareMenuManager { let unmount: ReturnType>; const mount = toMountPoint( - , + }, + }), rendering ); diff --git a/src/platform/plugins/shared/share/public/services/share_menu_registry.mock.ts b/src/platform/plugins/shared/share/public/services/share_menu_registry.mock.ts index df91c16b4fd1e..a898ebb533982 100644 --- a/src/platform/plugins/shared/share/public/services/share_menu_registry.mock.ts +++ b/src/platform/plugins/shared/share/public/services/share_menu_registry.mock.ts @@ -13,7 +13,7 @@ import { ShareMenuRegistrySetup, ShareMenuRegistryStart, } from './share_menu_registry'; -import { ShareContext, ShareConfigs } from '../types'; +import { ShareContext, ShareConfigs, ShareActionIntents } from '../types'; const createSetupMock = (): jest.Mocked => { const setup = { @@ -25,6 +25,9 @@ const createSetupMock = (): jest.Mocked => { const createStartMock = (): jest.Mocked => { const start = { + availableIntegrations: jest.fn( + (_objectType: string, _groupId?: string) => [] as ShareActionIntents[] + ), resolveShareItemsForShareContext: jest.fn( (_props: ShareContext & { isServerless: boolean }) => [] as ShareConfigs[] ), 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 934fe50c92d16..72dce7b87dad5 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 @@ -18,6 +18,8 @@ describe('ShareActionsRegistry', () => { getCapabilities: jest.fn(), getState: jest.fn(), }), + capabilities: { navLinks: {}, management: {}, catalogue: {} }, + getLicense: jest.fn(), }; describe('registerShareIntegration', () => { @@ -41,6 +43,110 @@ describe('ShareActionsRegistry', () => { }); describe('start', () => { + describe('availableIntegrations', () => { + it('returns by default the registered integrations without a prerequisite check', () => { + const shareRegistry = new ShareRegistry(); + + const { registerShareIntegration } = shareRegistry.setup(); + const { availableIntegrations } = shareRegistry.start(startDeps); + + // register a global integration without a prerequisite + registerShareIntegration({ + id: 'csvReports', + config: () => ({}), + }); + + // we expect to have 2 default share actions (link and embed) + 1 registered integration + expect(availableIntegrations('someRandomObjectType')).toHaveLength(2 + 1); + }); + + it('omits a registered integration that defines a prerequisite check which returns false', () => { + const shareRegistry = new ShareRegistry(); + + const { registerShareIntegration } = shareRegistry.setup(); + const { availableIntegrations } = shareRegistry.start(startDeps); + + const prerequisiteCheckFn = jest.fn(() => false); + + // register a global integration with a prerequisiteCheck + registerShareIntegration({ + id: 'csvReports', + config: () => ({}), + prerequisiteCheck: prerequisiteCheckFn, + }); + + // we expect to have just the 2 default share actions (link and embed) + expect(availableIntegrations('someRandomObjectType')).toHaveLength(2); + expect(prerequisiteCheckFn).toHaveBeenCalledWith( + expect.objectContaining({ + capabilities: startDeps.capabilities, + license: startDeps.getLicense(), + objectType: 'someRandomObjectType', + }) + ); + }); + + it('will include a registered integration that defines a prerequisite check which returns true', () => { + const shareRegistry = new ShareRegistry(); + + const { registerShareIntegration } = shareRegistry.setup(); + const { availableIntegrations } = shareRegistry.start(startDeps); + + const prerequisiteCheckFn = jest.fn(() => true); + + // register a global integration with a prerequisiteCheck + registerShareIntegration({ + id: 'csvReports', + config: () => ({}), + prerequisiteCheck: prerequisiteCheckFn, + }); + + // we expect to have 2 default share actions (link and embed) + 1 registered integration with a passing prerequisite + expect(availableIntegrations('anotherRandomObjectType')).toHaveLength(2 + 1); + expect(prerequisiteCheckFn).toHaveBeenCalledWith( + expect.objectContaining({ + capabilities: startDeps.capabilities, + license: startDeps.getLicense(), + objectType: 'anotherRandomObjectType', + }) + ); + }); + + it('will return only the registered integrations that match the requested integration groupId', () => { + const shareRegistry = new ShareRegistry(); + + const { registerShareIntegration } = shareRegistry.setup(); + const { availableIntegrations } = shareRegistry.start(startDeps); + + // register a global integration with a groupId + registerShareIntegration({ + id: 'csvReports', + groupId: 'export', + config: () => ({}), + }); + + // we only expect to have the global integration we registered with the groupId 'export' + expect(availableIntegrations('someRandomObjectType', 'export')).toHaveLength(1); + }); + + it('will return only the registered integrations that match the requested integration groupId and objectType', () => { + const shareRegistry = new ShareRegistry(); + + const { registerShareIntegration } = shareRegistry.setup(); + const { availableIntegrations } = shareRegistry.start(startDeps); + + // register a scoped integration with a groupId + registerShareIntegration('scoped', { + id: 'csvReports', + groupId: 'export', + config: () => ({}), + }); + + expect(availableIntegrations('scoped', 'export')).toHaveLength(1); + expect(availableIntegrations('someRandomObjectType', 'export')).toHaveLength(0); + }); + }); + describe('resolveShareItemsForShareContext', () => { test('it returns the default share actions for any requested app scope without performing any prior registrations', () => { const { resolveShareItemsForShareContext } = new ShareRegistry().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 809ad8e78b208..e9d3b57a7046b 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 @@ -7,6 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { ApplicationStart } from '@kbn/core/public'; +import type { ILicense } from '@kbn/licensing-plugin/public'; import type { BrowserUrlService, ShareContext, @@ -23,6 +25,8 @@ import type { AnonymousAccessServiceContract } from '../../common/anonymous_acce export class ShareRegistry implements ShareRegistryPublicApi { private urlService?: BrowserUrlService; private anonymousAccessServiceProvider?: () => AnonymousAccessServiceContract; + private capabilities?: ApplicationStart['capabilities']; + private getLicense?: () => ILicense | undefined; private readonly globalMarker: string = '*'; @@ -49,11 +53,19 @@ export class ShareRegistry implements ShareRegistryPublicApi { }; } - start({ urlService, anonymousAccessServiceProvider }: ShareRegistryApiStart) { + start({ + urlService, + anonymousAccessServiceProvider, + capabilities, + getLicense, + }: ShareRegistryApiStart) { this.urlService = urlService; this.anonymousAccessServiceProvider = anonymousAccessServiceProvider; + this.capabilities = capabilities; + this.getLicense = getLicense; return { + availableIntegrations: this.availableIntegrations.bind(this), resolveShareItemsForShareContext: this.resolveShareItemsForShareContext.bind(this), }; } @@ -137,6 +149,36 @@ export class ShareRegistry implements ShareRegistryPublicApi { return globalOptions.concat(Array.from(shareContextMap.values())); } + /** + * Returns all share actions that are available for the given object type. + */ + private availableIntegrations(objectType: string, groupId?: string): ShareActionIntents[] { + if (!this.capabilities || !this.getLicense) { + throw new Error('ShareOptionsManager#start was not invoked'); + } + + return this.getShareConfigOptionsForObject(objectType).filter((share) => { + if ( + groupId && + (share.shareType !== 'integration' || + (share?.groupId !== groupId && share.shareType === 'integration')) + ) { + return false; + } + + if (share.shareType === 'integration' && share.prerequisiteCheck) { + return share.prerequisiteCheck({ + capabilities: this.capabilities!, + license: this.getLicense!(), + objectType, + }); + } + + // if no activation requirement is provided, assume that the share action is always available + return true; + }); + } + private resolveShareItemsForShareContext({ objectType, isServerless, @@ -146,7 +188,7 @@ export class ShareRegistry implements ShareRegistryPublicApi { throw new Error('ShareOptionsManager#start was not invoked'); } - return this.getShareConfigOptionsForObject(objectType) + return this.availableIntegrations(objectType) .map((shareAction) => { let config: ShareConfigs['config'] | null; diff --git a/src/platform/plugins/shared/share/public/types.ts b/src/platform/plugins/shared/share/public/types.ts index 14ef1841787af..1f5c5558177c8 100644 --- a/src/platform/plugins/shared/share/public/types.ts +++ b/src/platform/plugins/shared/share/public/types.ts @@ -9,20 +9,25 @@ import type { ComponentType, ReactNode } from 'react'; import type { InjectedIntl } from '@kbn/i18n-react'; -import { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import { EuiContextMenuPanelDescriptor, type EuiCodeProps, type EuiIconProps } from '@elastic/eui'; import { EuiContextMenuPanelItemDescriptorEntry } from '@elastic/eui/src/components/context_menu/context_menu'; +import type { ILicense } from '@kbn/licensing-plugin/public'; import type { Capabilities } from '@kbn/core/public'; -import type { EuiIconProps } from '@elastic/eui'; 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'; import { AnonymousAccessServiceContract } from '../common/anonymous_access'; export interface ShareRegistryApiStart { + capabilities: Capabilities; urlService: BrowserUrlService; anonymousAccessServiceProvider?: () => AnonymousAccessServiceContract; + getLicense: () => ILicense | undefined; } +type ShareActionConfigArgs = ShareContext & + Pick; + export type ShareTypes = 'link' | 'embed' | 'legacy' | 'integration'; export type InternalShareActionIntent = Exclude; @@ -54,11 +59,20 @@ type ShareImplementationFactory< id: string; groupId?: string; shareType: T; - config: (ctx: ShareContext & ShareRegistryApiStart) => C | null; + config: (ctx: ShareActionConfigArgs) => C; + /** + * 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: { + capabilities: Capabilities; + objectType: ShareContext['objectType']; + license?: ILicense; + }) => boolean; } : { shareType: T; - config: (ctx: ShareContext & ShareRegistryApiStart) => C | null; + config: (ctx: ShareActionConfigArgs) => C | null; }; // New type definition to extract the config return type @@ -107,39 +121,49 @@ export interface ShareLegacy { * @description Share integration implementation definition for performing exports within kibana */ export interface ExportShare - extends ShareIntegration<{ - /** - * @deprecated only kept around for legacy reasons - */ - name?: string; - /** - * @deprecated only kept around for legacy reasons - */ - icon?: EuiIconProps['type']; - /** - * @deprecated only kept around for legacy reasons - */ - sortOrder?: number; - /** - * @deprecated only kept around for legacy reasons - */ - toolTipContent?: string; - label: string; - exportType: string; - /** - * allows disabling the export action based on there factors, for instance licensing - */ - disabled?: boolean; - helpText?: ReactNode; - generateExportButton?: ReactNode; - generateAssetExport: (args: ExportGenerationOpts) => Promise; - generateAssetURIValue: (args: ExportGenerationOpts) => string | undefined; - renderCopyURIButton?: boolean; - warnings?: Array<{ title: string; message: string }>; - requiresSavedState?: boolean; - supportedLayoutOptions?: Array<'print'>; - renderLayoutOptionSwitch?: boolean; - }> { + extends ShareIntegration< + { + /** + * @deprecated only kept around for legacy reasons + */ + name?: string; + icon?: EuiIconProps['type']; + sortOrder?: number; + /** + * @deprecated only kept around for legacy reasons + */ + toolTipContent?: string; + label: string; + exportType: string; + /** + * allows disabling the export action, for instance the current app has no data to export + */ + disabled?: boolean; + helpText?: ReactNode; + generateExportButtonLabel?: ReactNode; + generateAssetExport: (args: ExportGenerationOpts) => Promise; + renderCopyURIButton?: boolean; + warnings?: Array<{ title: string; message: string }>; + requiresSavedState?: boolean; + supportedLayoutOptions?: Array<'print'>; + renderLayoutOptionSwitch?: boolean; + } & ( + | { + generateAssetComponent?: never; + copyAssetURIConfig: { + headingText: string; + helpText?: string; + contentType: EuiCodeProps['language']; + generateAssetURIValue: (args: ExportGenerationOpts) => string | undefined; + }; + } + | { generateAssetComponent: ReactNode; copyAssetURIConfig?: never } + | { + generateAssetComponent?: never; + copyAssetURIConfig?: never; + } + ) + > { groupId: 'export'; } @@ -179,7 +203,10 @@ export type EmbedShareUIConfig = ShareActionUserInputBase<{ showPublicUrlSwitch?: (anonymousUserCapabilities: Capabilities) => boolean; }>; -type ExportShareUIConfig = ShareActionUserInputBase<{}>; +/** + * @description record of user config for each registered export integration + */ +type ExportShareUIConfig = Record>; export interface ShareUIConfig { link: LinkShareUIConfig; @@ -237,6 +264,10 @@ export interface ShareContext { * The type of the object to share. for example lens, dashboard, etc. */ objectType: string; + /** + * An alias of type of the object to share, that's more human friendly. + */ + objectTypeAlias?: string; /** * Allows for passing contextual information that each consumer can provide to customize the share menu */ @@ -316,6 +347,7 @@ export interface UrlParamExtension { /** @public */ export interface ShowShareMenuOptions extends Omit { + asExport?: boolean; anchorElement?: HTMLElement; allowShortUrl: boolean; onClose?: () => void; diff --git a/src/platform/plugins/shared/share/tsconfig.json b/src/platform/plugins/shared/share/tsconfig.json index b5f2c468d4544..cd84e8eed26e2 100644 --- a/src/platform/plugins/shared/share/tsconfig.json +++ b/src/platform/plugins/shared/share/tsconfig.json @@ -1,9 +1,9 @@ { "extends": "../../../../../tsconfig.base.json", "compilerOptions": { - "outDir": "target/types", + "outDir": "target/types" }, - "include": ["common/**/*", "public/**/*", "server/**/*"], + "include": ["common/**/*", "public/**/*", "server/**/*", "../../../../../typings/emotion.d.ts"], "kbn_references": [ "@kbn/core", "@kbn/kibana-utils-plugin", @@ -20,7 +20,6 @@ "@kbn/shared-ux-prompt-not-found", "@kbn/react-kibana-mount", "@kbn/shared-ux-tabbed-modal", - "@kbn/test-jest-helpers", "@kbn/core-user-profile-browser", "@kbn/datemath", "@kbn/core-http-server-internal", @@ -33,8 +32,7 @@ "@kbn/std", "@kbn/core-logging-server-mocks", "@kbn/core-http-router-server-mocks", + "@kbn/licensing-plugin" ], - "exclude": [ - "target/**/*", - ] + "exclude": ["target/**/*"] } diff --git a/src/platform/plugins/shared/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx b/src/platform/plugins/shared/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx index ea01b6584c3fb..c5982668be2bf 100644 --- a/src/platform/plugins/shared/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx +++ b/src/platform/plugins/shared/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx @@ -134,6 +134,8 @@ describe('getTopNavConfig', () => { Object { "description": "Share Visualization", "disableButton": false, + "iconOnly": true, + "iconType": "share", "id": "share", "label": "share", "run": [Function], @@ -162,6 +164,60 @@ describe('getTopNavConfig', () => { ] `); }); + test('returns correct links that include when export integrations are available', () => { + const vis = { + savedVis: { + id: 'test', + sharingSavedObjectProps: { + outcome: 'conflict', + aliasTargetId: 'alias_id', + }, + }, + vis: { + type: { + title: 'TSVB', + }, + }, + } as VisualizeEditorVisInstance; + + const availableExportIntegrationsSpy = jest.spyOn(share, 'availableIntegrations'); + + availableExportIntegrationsSpy.mockImplementationOnce((_objectType, groupId) => { + if (groupId === 'export') { + return [ + { + id: 'export', + shareType: 'integration', + groupId: 'export', + config: () => ({}), + }, + ]; + } + + return []; + }); + + const topNavLinks = getTopNavConfig( + { + hasUnsavedChanges: false, + setHasUnsavedChanges: jest.fn(), + hasUnappliedChanges: false, + onOpenInspector: jest.fn(), + originatingApp: 'dashboards', + setOriginatingApp: jest.fn(), + visInstance: vis, + stateContainer, + visualizationIdFromUrl: undefined, + stateTransfer: createEmbeddableStateTransferMock(), + } as unknown as TopNavConfigParams, + services + ); + + expect(topNavLinks.find(({ id }) => id === 'export')).toBeDefined(); + + // revert mock implementation + availableExportIntegrationsSpy.mockRestore(); + }); test('returns correct links if the originating app is undefined', () => { const vis = { savedVis: { @@ -207,6 +263,8 @@ describe('getTopNavConfig', () => { Object { "description": "Share Visualization", "disableButton": false, + "iconOnly": true, + "iconType": "share", "id": "share", "label": "share", "run": [Function], @@ -314,6 +372,8 @@ describe('getTopNavConfig', () => { Object { "description": "Share Visualization", "disableButton": false, + "iconOnly": true, + "iconType": "share", "id": "share", "label": "share", "run": [Function], @@ -399,6 +459,8 @@ describe('getTopNavConfig', () => { Object { "description": "Share Visualization", "disableButton": true, + "iconOnly": true, + "iconType": "share", "id": "share", "label": "share", "run": [Function], @@ -495,6 +557,8 @@ describe('getTopNavConfig', () => { Object { "description": "Share Visualization", "disableButton": false, + "iconOnly": true, + "iconType": "share", "id": "share", "label": "share", "run": [Function], diff --git a/src/platform/plugins/shared/visualizations/public/visualize_app/utils/get_top_nav_config.tsx b/src/platform/plugins/shared/visualizations/public/visualize_app/utils/get_top_nav_config.tsx index 84657cda7baf9..ee9966d819804 100644 --- a/src/platform/plugins/shared/visualizations/public/visualize_app/utils/get_top_nav_config.tsx +++ b/src/platform/plugins/shared/visualizations/public/visualize_app/utils/get_top_nav_config.tsx @@ -11,7 +11,7 @@ import React from 'react'; import moment from 'moment'; import EventEmitter from 'events'; import { i18n } from '@kbn/i18n'; -import { EuiBetaBadgeProps } from '@elastic/eui'; +import { EuiBetaBadgeProps, EuiCallOut } from '@elastic/eui'; import { parse } from 'query-string'; import { Capabilities } from '@kbn/core/public'; @@ -290,6 +290,118 @@ export const getTopNavConfig = ( const showSaveButton = visualizeCapabilities.save || (!originatingApp && dashboardCapabilities.showWriteControls); + const showShareOptions = (anchorElement: HTMLElement, asExport?: boolean) => { + if (share) { + const currentState = stateContainer.getState(); + const searchParams = parse(history.location.search); + const params: VisualizeLocatorParams = { + visId: savedVis?.id, + filters: currentState.filters, + refreshInterval: undefined, + timeRange: data.query.timefilter.timefilter.getTime(), + uiState: currentState.uiState, + query: currentState.query, + vis: currentState.vis, + linked: currentState.linked, + indexPattern: + visInstance.savedSearch?.searchSource?.getField('index')?.id ?? + (searchParams.indexPattern as string), + savedSearchId: visInstance.savedSearch?.id ?? (searchParams.savedSearchId as string), + }; + // TODO: support sharing in by-value mode + share.toggleShareContextMenu({ + asExport, + anchorElement, + allowShortUrl: Boolean(visualizeCapabilities.createShortUrl), + shareableUrl: unhashUrl(window.location.href), + objectId: savedVis?.id, + objectType: 'visualization', + objectTypeMeta: { + title: i18n.translate('visualizations.share.shareModal.title', { + defaultMessage: 'Share this visualization', + }), + config: { + embed: { + computeAnonymousCapabilities: showPublicUrlSwitch, + }, + integration: { + export: { + pdfReports: { + draftModeCallOut: ( + + {i18n.translate( + 'visualizations.exports.pdfReports.postURLWatcherMessage.unsavedChanges', + { + defaultMessage: 'URL may change if you upgrade Kibana.', + } + )} + + ), + }, + imageReports: { + draftModeCallOut: ( + + {i18n.translate( + 'visualizations.exports.imageReports.postURLWatcherMessage.unsavedChanges', + { + defaultMessage: 'URL may change if you upgrade Kibana.', + } + )} + + ), + }, + csvReports: { + draftModeCallOut: ( + + {i18n.translate( + 'visualizations.exports.csvReports.postURLWatcherMessage.unsavedChanges', + { + defaultMessage: 'URL may change if you upgrade Kibana.', + } + )} + + ), + }, + }, + }, + }, + }, + sharingData: { + title: + savedVis?.title || + i18n.translate('visualizations.reporting.defaultReportTitle', { + defaultMessage: 'Visualization [{date}]', + values: { date: moment().toISOString(true) }, + }), + locatorParams: { + id: VISUALIZE_APP_LOCATOR, + version: getKibanaVersion(), + params, + }, + }, + isDirty: hasUnappliedChanges || hasUnsavedChanges, + }); + } + }; + const topNavMenu: TopNavMenuData[] = [ ...(displayEditInLensItem ? [ @@ -372,8 +484,33 @@ export const getTopNavConfig = ( } }, }, + // Only show the export button if the current user meets the requirements for at least one registered export integration + ...(Boolean(share?.availableIntegrations('visualization', 'export')?.length) + ? ([ + { + id: 'export', + iconType: 'download', + iconOnly: true, + label: i18n.translate('visualizations.topNavMenu.shareVisualizationButtonLabel', { + defaultMessage: 'export', + }), + description: i18n.translate( + 'visualizations.topNavMenu.shareVisualizationButtonAriaLabel', + { + defaultMessage: 'Export Visualization', + } + ), + testId: 'exportTopNavButton', + run: (anchorElement) => showShareOptions(anchorElement, true), + // disable the Share button if no action specified and fot byValue visualizations + disableButton: !share || Boolean(!savedVis.id && originatingApp), + }, + ] as TopNavMenuData[]) + : []), { id: 'share', + iconType: 'share', + iconOnly: true, label: i18n.translate('visualizations.topNavMenu.shareVisualizationButtonLabel', { defaultMessage: 'share', }), @@ -381,58 +518,7 @@ export const getTopNavConfig = ( defaultMessage: 'Share Visualization', }), testId: 'shareTopNavButton', - run: (anchorElement) => { - if (share) { - const currentState = stateContainer.getState(); - const searchParams = parse(history.location.search); - const params: VisualizeLocatorParams = { - visId: savedVis?.id, - filters: currentState.filters, - refreshInterval: undefined, - timeRange: data.query.timefilter.timefilter.getTime(), - uiState: currentState.uiState, - query: currentState.query, - vis: currentState.vis, - linked: currentState.linked, - indexPattern: - visInstance.savedSearch?.searchSource?.getField('index')?.id ?? - (searchParams.indexPattern as string), - savedSearchId: visInstance.savedSearch?.id ?? (searchParams.savedSearchId as string), - }; - // TODO: support sharing in by-value mode - share.toggleShareContextMenu({ - anchorElement, - allowShortUrl: Boolean(visualizeCapabilities.createShortUrl), - shareableUrl: unhashUrl(window.location.href), - objectId: savedVis?.id, - objectType: 'visualization', - objectTypeMeta: { - title: i18n.translate('visualizations.share.shareModal.title', { - defaultMessage: 'Share this visualization', - }), - config: { - embed: { - computeAnonymousCapabilities: showPublicUrlSwitch, - }, - }, - }, - sharingData: { - title: - savedVis?.title || - i18n.translate('visualizations.reporting.defaultReportTitle', { - defaultMessage: 'Visualization [{date}]', - values: { date: moment().toISOString(true) }, - }), - locatorParams: { - id: VISUALIZE_APP_LOCATOR, - version: getKibanaVersion(), - params, - }, - }, - isDirty: hasUnappliedChanges || hasUnsavedChanges, - }); - } - }, + run: showShareOptions, // disable the Share button if no action specified and fot byValue visualizations disableButton: !share || Boolean(!savedVis.id && originatingApp), }, diff --git a/src/platform/test/functional/page_objects/export_page.ts b/src/platform/test/functional/page_objects/export_page.ts new file mode 100644 index 0000000000000..a13ca754cec6e --- /dev/null +++ b/src/platform/test/functional/page_objects/export_page.ts @@ -0,0 +1,71 @@ +/* + * 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 { FtrService } from '../ftr_provider_context'; + +export class ExportPageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly find = this.ctx.getService('find'); + private readonly log = this.ctx.getService('log'); + private readonly retry = this.ctx.getService('retry'); + + async exportButtonExists() { + return await this.testSubjects.exists('exportTopNavButton'); + } + + async clickExportTopNavButton() { + return this.testSubjects.click('exportTopNavButton'); + } + + async isExportPopoverOpen() { + return await this.testSubjects.exists('exportPopover'); + } + + async isPopoverItemEnabled(label: string) { + let isEnabled; + if (!(isEnabled = await this.testSubjects.isEnabled(`exportMenuItem-${label}`))) { + this.log.debug(`isPopoverItemEnabled: ${label} is not enabled`); + } + return isEnabled; + } + + async clickPopoverItem(label: string) { + this.log.debug(`clickPopoverItem label: ${label}`); + + await this.retry.waitFor('ascertain that export popover is open', async () => { + const isExportPopoverOpen = await this.isExportPopoverOpen(); + if (!isExportPopoverOpen) { + await this.clickExportTopNavButton(); + } + return isExportPopoverOpen; + }); + + await this.testSubjects.click(`exportMenuItem-${label}`); + } + + async isExportFlyoutOpen() { + return await this.testSubjects.exists('exportItemDetailsFlyout'); + } + + async closeExportFlyout() { + if (await this.isExportFlyoutOpen()) { + await this.testSubjects.click('exportFlyoutCloseButton'); + } + } + + async getExportAssetTextButton() { + return await this.find.byCssSelector( + '[data-test-subj="exportItemDetailsFlyout"] [data-test-subj="euiCodeBlockCopy"]' + ); + } + + async copyExportAssetText() { + await (await this.getExportAssetTextButton()).click(); + } +} diff --git a/src/platform/test/functional/page_objects/index.ts b/src/platform/test/functional/page_objects/index.ts index e5a0a619b4c34..2a11f43727a27 100644 --- a/src/platform/test/functional/page_objects/index.ts +++ b/src/platform/test/functional/page_objects/index.ts @@ -13,6 +13,7 @@ import { ContextPageObject } from './context_page'; import { DashboardPageObject } from './dashboard_page'; import { DiscoverPageObject } from './discover_page'; import { ErrorPageObject } from './error_page'; +import { ExportPageObject } from './export_page'; import { HeaderPageObject } from './header_page'; import { HomePageObject } from './home_page'; import { NewsfeedPageObject } from './newsfeed_page'; @@ -52,6 +53,7 @@ export const pageObjects = { discover: DiscoverPageObject, embeddedConsole: EmbeddedConsoleProvider, error: ErrorPageObject, + exports: ExportPageObject, header: HeaderPageObject, home: HomePageObject, newsfeed: NewsfeedPageObject, diff --git a/src/platform/test/functional/page_objects/share_page.ts b/src/platform/test/functional/page_objects/share_page.ts index 89f606d558bf0..4adf3299bce38 100644 --- a/src/platform/test/functional/page_objects/share_page.ts +++ b/src/platform/test/functional/page_objects/share_page.ts @@ -45,7 +45,7 @@ export class SharePageObject extends FtrService { return this.testSubjects.click('shareTopNavButton'); } - async openShareModalItem(itemTitle: 'link' | 'export' | 'embed') { + async openShareModalItem(itemTitle: 'link' | 'embed') { this.log.debug(`openShareModalItem title: ${itemTitle}`); const isShareModalOpen = await this.isShareModalOpen(); if (!isShareModalOpen) { diff --git a/x-pack/platform/plugins/private/reporting/public/plugin.ts b/x-pack/platform/plugins/private/reporting/public/plugin.ts index c6bacfd886d85..823dacccfa8c8 100644 --- a/x-pack/platform/plugins/private/reporting/public/plugin.ts +++ b/x-pack/platform/plugins/private/reporting/public/plugin.ts @@ -210,45 +210,35 @@ export class ReportingPublicPlugin }) ); - startServices$.subscribe(([{ application }, { licensing }]) => { - licensing.license$.subscribe((license) => { - shareSetup.registerShareIntegration( - 'search', - // TODO: export the reporting pdf export provider for registration in the actual plugins that depend on it - reportingCsvExportProvider({ - apiClient, - license, - application, - usesUiCapabilities, - startServices$, - }) - ); + shareSetup.registerShareIntegration( + 'search', + // TODO: export the reporting pdf export provider for registration in the actual plugins that depend on it + reportingCsvExportProvider({ + apiClient, + startServices$, + usesUiCapabilities, + }) + ); - if (this.config.export_types.pdf.enabled || this.config.export_types.png.enabled) { - shareSetup.registerShareIntegration( - // TODO: export the reporting pdf export provider for registration in the actual plugins that depend on it - reportingPDFExportProvider({ - apiClient, - license, - application, - startServices$, - usesUiCapabilities, - }) - ); + if (this.config.export_types.pdf.enabled || this.config.export_types.png.enabled) { + shareSetup.registerShareIntegration( + // TODO: export the reporting pdf export provider for registration in the actual plugins that depend on it + reportingPDFExportProvider({ + apiClient, + startServices$, + usesUiCapabilities, + }) + ); - shareSetup.registerShareIntegration( - // TODO: export the reporting pdf export provider for registration in the actual plugins that depend on it - reportingPNGExportProvider({ - apiClient, - license, - application, - usesUiCapabilities, - startServices$, - }) - ); - } - }); - }); + shareSetup.registerShareIntegration( + // TODO: export the reporting pdf export provider for registration in the actual plugins that depend on it + reportingPNGExportProvider({ + apiClient, + startServices$, + usesUiCapabilities, + }) + ); + } this.startServices$ = startServices$; return this.getContract(apiClient, startServices$); diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 0b267f6573a2a..9cca29ce3c8ad 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -1560,6 +1560,10 @@ "dashboard.emptyScreen.noPermissionsTitle": "Ce tableau de bord est vide.", "dashboard.emptyScreen.viewModeSubtitle": "Accédez au mode de modification, puis commencez à ajouter vos visualisations.", "dashboard.emptyScreen.viewModeTitle": "Ajouter des visualisations à votre tableau de bord", + "dashboard.exports.imageReports.warning.title": "Modifications non enregistrées", + "dashboard.exports.imageReports.postURLWatcherMessage.unsavedChanges": "L'URL peut changer si vous mettez Kibana à niveau.", + "dashboard.exports.pdfReports.warning.title": "Modifications non enregistrées", + "dashboard.exports.pdfReports.postURLWatcherMessage.unsavedChanges": "L'URL peut changer si vous mettez Kibana à niveau.", "dashboard.featureCatalogue.dashboardDescription": "Affichez et partagez une collection de visualisations et de résultats de recherche.", "dashboard.featureCatalogue.dashboardSubtitle": "Analysez des données à l’aide de tableaux de bord.", "dashboard.featureCatalogue.dashboardTitle": "Dashboard", @@ -2762,6 +2766,8 @@ "discover.esqlToDataViewTransitionModal.saveButtonLabel": "Sauvegarder et basculer", "discover.esqlToDataViewTransitionModal.title": "Modifications non enregistrées", "discover.esqlToDataviewTransitionModalBody": "Un changement de vue de données supprime la requête ES|QL en cours. Enregistrez cette session pour éviter de perdre votre travail.", + "discover.exports.csvReports.warning.title": "Modifications non enregistrées", + "discover.exports.csvReports.postURLWatcherMessage.unsavedChanges": "L'URL peut changer si vous mettez Kibana à niveau.", "discover.fieldChooser.availableFieldsTooltip": "Champs disponibles pour l'affichage dans le tableau.", "discover.fieldChooser.discoverField.addFieldTooltip": "Ajouter le champ en tant que colonne", "discover.fieldChooser.discoverField.removeFieldTooltip": "Supprimer le champ du tableau", @@ -7925,7 +7931,6 @@ "share.contextMenu.embedCodeLabel": "Incorporer le code", "share.contextMenu.embedCodePanelTitle": "Incorporer le code", "share.contextMenu.embedCodeTab": "Intégrer", - "share.contextMenu.exportCodeTab": "Exporter", "share.contextMenu.permalinkPanelTitle": "Obtenir le lien", "share.contextMenu.permalinksLabel": "Obtenir les liens", "share.contextMenu.permalinksTab": "Liens", @@ -7937,17 +7942,9 @@ "share.embed.helpText": "Intégrez ce {objectType} dans une autre page web.", "share.embed.publicUrlOptionsSwitch.label": "Autoriser l'accès public", "share.embed.publicUrlOptionsSwitch.tooltip": "L'activation de l'accès public génère une URL partageable qui permet un accès anonyme sans invite de connexion.", - "share.export.generateButtonLabel": "Exporter un fichier", - "share.export.helpText": "Sélectionnez le type de fichier que vous souhaitez exporter pour cette visualisation.", - "share.fileType": "Type de fichier", + "share.exportFlyoutContent.optimizeForPrinting.helpText": "Utilise plusieurs pages, affichant au maximum 2 visualisations par page", "share.link.copied": "Lien copié", "share.link.copyLinkButton": "Copier le lien", - "share.link.warning.title": "Modifications non enregistrées", - "share.modalContent.copyUrlButtonLabel": "Copier l'URL Post", - "share.postURLWatcherMessage": "Copiez cette URL POST pour appeler la génération depuis l'extérieur de Kibana ou à partir de Watcher.", - "share.postURLWatcherMessage.unsavedChanges": "L'URL peut changer si vous mettez Kibana à niveau.", - "share.screenCapturePanelContent.optimizeForPrintingHelpText": "Utilise plusieurs pages, affichant au maximum 2 visualisations par page", - "share.screenCapturePanelContent.optimizeForPrintingLabel": "Pour l'impression", "share.urlPanel.canNotShareAsSavedObjectHelpText": "Pour le partager comme objet enregistré, enregistrez le {objectType}.", "share.urlPanel.copyIframeCodeButtonLabel": "Copier le code iFrame", "share.urlPanel.copyLinkButtonLabel": "Copier le lien", @@ -9996,6 +9993,12 @@ "visualizations.embeddable.tsdbRollupWarning": "La visualisation utilise une fonction qui n'est pas prise en charge par les données cumulées. Sélectionnez une autre fonction ou modifiez la plage temporelle.", "visualizations.experimentalVisInfoText": "Elle pourra être modifiée ou supprimée totalement dans une prochaine version. Elastic s'efforcera de corriger tous les problèmes, mais les fonctionnalités en version d'évaluation technique ne sont pas soumises aux accords de niveau de service d'assistance des fonctionnalités officielles en disponibilité générale. Pour apporter des commentaires, veuillez créer une entrée dans {githubLink}.", "visualizations.experimentalVisInfoTitle": "Cette fonctionnalité est en version d'évaluation technique.", + "visualizations.exports.imageReports.warning.title": "Modifications non enregistrées", + "visualizations.exports.imageReports.postURLWatcherMessage.unsavedChanges": "L'URL peut changer si vous mettez Kibana à niveau.", + "visualizations.exports.csvReports.warning.title": "Modifications non enregistrées", + "visualizations.exports.csvReports.postURLWatcherMessage.unsavedChanges": "L'URL peut changer si vous mettez Kibana à niveau.", + "visualizations.exports.pdfReports.warning.title": "Modifications non enregistrées", + "visualizations.exports.pdfReports.postURLWatcherMessage.unsavedChanges": "L'URL peut changer si vous mettez Kibana à niveau.", "visualizations.fallbackDataView.label": "{type} introuvable", "visualizations.function.findAccessorOrFail.error.accessor": "Le nom de la colonne ou l'index fourni sont non valides : {accessor}", "visualizations.function.range.from.help": "Début de la plage", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index ee811afecf7c7..833b6bcb90bfb 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -1560,6 +1560,10 @@ "dashboard.emptyScreen.noPermissionsTitle": "このダッシュボードは空です。", "dashboard.emptyScreen.viewModeSubtitle": "編集モードに切り替えて、ビジュアライゼーションの追加を開始します。", "dashboard.emptyScreen.viewModeTitle": "ダッシュボードにビジュアライゼーションを追加", + "dashboard.exports.imageReports.warning.title": "保存されていない変更", + "dashboard.exports.imageReports.postURLWatcherMessage.unsavedChanges": "POST URLをコピー", + "dashboard.exports.pdfReports.warning.title": "保存されていない変更", + "dashboard.exports.pdfReports.postURLWatcherMessage.unsavedChanges": "POST URLをコピー", "dashboard.featureCatalogue.dashboardDescription": "ビジュアライゼーションと検索結果のコレクションの表示と共有を行います。", "dashboard.featureCatalogue.dashboardSubtitle": "ダッシュボードでデータを分析します。", "dashboard.featureCatalogue.dashboardTitle": "ダッシュボード", @@ -2760,6 +2764,8 @@ "discover.esqlToDataViewTransitionModal.saveButtonLabel": "保存して切り替え", "discover.esqlToDataViewTransitionModal.title": "保存されていない変更", "discover.esqlToDataviewTransitionModalBody": "データビューを切り替えると、現在のES|QLクエリが削除されます。作業が失われないようにするには、このセッションを保存してください。", + "discover.exports.csvReports.warning.title": "保存されていない変更", + "discover.exports.csvReports.postURLWatcherMessage.unsavedChanges": "POST URLをコピー", "discover.fieldChooser.availableFieldsTooltip": "フィールドをテーブルに表示できます。", "discover.fieldChooser.discoverField.addFieldTooltip": "フィールドを列として追加", "discover.fieldChooser.discoverField.removeFieldTooltip": "フィールドを表から削除", @@ -7916,7 +7922,6 @@ "share.contextMenu.embedCodeLabel": "埋め込みコード", "share.contextMenu.embedCodePanelTitle": "埋め込みコード", "share.contextMenu.embedCodeTab": "埋め込み ", - "share.contextMenu.exportCodeTab": "エクスポート", "share.contextMenu.permalinkPanelTitle": "リンクを取得", "share.contextMenu.permalinksLabel": "リンクを取得", "share.contextMenu.permalinksTab": "リンク", @@ -7928,17 +7933,8 @@ "share.embed.helpText": "この{objectType}を別のWebページに埋め込みます。", "share.embed.publicUrlOptionsSwitch.label": "公開アクセスを許可", "share.embed.publicUrlOptionsSwitch.tooltip": "公開アクセスを有効にすると、ログイン画面を表示せずに匿名アクセスを許可する共有可能なURLが生成されます。", - "share.export.generateButtonLabel": "ファイルのエクスポート", - "share.export.helpText": "このビジュアライゼーションでエクスポートするファイルタイプを選択します。", - "share.fileType": "ファイルタイプ", + "share.exportFlyoutContent.optimizeForPrinting.helpText": "複数のページを使用します。ページごとに最大2のビジュアライゼーションが表示されます", "share.link.copied": "リンクがコピーされました", - "share.link.copyLinkButton": "リンクをコピー", - "share.link.warning.title": "保存されていない変更", - "share.modalContent.copyUrlButtonLabel": "POST URLをコピー", - "share.postURLWatcherMessage": "POST URLをコピーしてKibana外または旧Watcherから生成を呼び出します。", - "share.postURLWatcherMessage.unsavedChanges": "Kibanaをアップグレードした場合、URLが変更されることがあります。", - "share.screenCapturePanelContent.optimizeForPrintingHelpText": "複数のページを使用します。ページごとに最大2のビジュアライゼーションが表示されます", - "share.screenCapturePanelContent.optimizeForPrintingLabel": "印刷用", "share.urlPanel.canNotShareAsSavedObjectHelpText": "保存されたオブジェクトを共有するには、{objectType}を保存してください。", "share.urlPanel.copyIframeCodeButtonLabel": "iFrame コードをコピー", "share.urlPanel.copyLinkButtonLabel": "リンクをコピー", @@ -9985,6 +9981,12 @@ "visualizations.embeddable.tsdbRollupWarning": "ビジュアライゼーションは、ロールアップされたデータによってサポートされていない関数を使用しています。別の関数を選択するか、時間範囲を選択してください。", "visualizations.experimentalVisInfoText": "将来のリリースでは、変更されるか、完全に削除される場合があります。Elasticはすべての問題の修正に努めますが、テクニカルプレビュー中の機能には正式なGA機能のサポートSLAが適用されません。フィードバックがある場合は、{githubLink}で問題を報告してください。", "visualizations.experimentalVisInfoTitle": "この機能はテクニカルプレビュー中です。", + "visualizations.exports.imageReports.warning.title": "保存されていない変更", + "visualizations.exports.imageReports.postURLWatcherMessage.unsavedChanges": "POST URLをコピー", + "visualizations.exports.csvReports.warning.title": "保存されていない変更", + "visualizations.exports.csvReports.postURLWatcherMessage.unsavedChanges": "POST URLをコピー", + "visualizations.exports.pdfReports.warning.title": "保存されていない変更", + "visualizations.exports.pdfReports.postURLWatcherMessage.unsavedChanges": "POST URLをコピー", "visualizations.fallbackDataView.label": "{type}が見つかりません", "visualizations.function.findAccessorOrFail.error.accessor": "入力された列名またはインデックスが無効です:{accessor}", "visualizations.function.range.from.help": "範囲の開始", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index a8deda5233c74..46efdd016bfcd 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -1560,6 +1560,10 @@ "dashboard.emptyScreen.noPermissionsTitle": "此仪表板是空的。", "dashboard.emptyScreen.viewModeSubtitle": "进入编辑模式,然后开始添加可视化。", "dashboard.emptyScreen.viewModeTitle": "将可视化添加到仪表板", + "dashboard.exports.imageReports.warning.title": "保存されていない変更", + "dashboard.exports.imageReports.postURLWatcherMessage.unsavedChanges": "POST URLをコピー", + "dashboard.exports.pdfReports.warning.title": "保存されていない変更", + "dashboard.exports.pdfReports.postURLWatcherMessage.unsavedChanges": "POST URLをコピー", "dashboard.featureCatalogue.dashboardDescription": "显示和共享可视化和搜索结果的集合。", "dashboard.featureCatalogue.dashboardSubtitle": "在仪表板中分析数据。", "dashboard.featureCatalogue.dashboardTitle": "仪表板", @@ -2763,6 +2767,8 @@ "discover.esqlToDataViewTransitionModal.saveButtonLabel": "保存并切换", "discover.esqlToDataViewTransitionModal.title": "未保存的更改", "discover.esqlToDataviewTransitionModalBody": "切换数据视图会移除当前的 ES|QL 查询。保存此会话以避免丢失工作。", + "discover.exports.csvReports.warning.title": "保存されていない変更", + "discover.exports.csvReports.postURLWatcherMessage.unsavedChanges": "POST URLをコピー", "discover.fieldChooser.availableFieldsTooltip": "适用于在表中显示的字段。", "discover.fieldChooser.discoverField.addFieldTooltip": "将字段添加为列", "discover.fieldChooser.discoverField.removeFieldTooltip": "从表中移除字段", @@ -7931,7 +7937,6 @@ "share.contextMenu.embedCodeLabel": "嵌入代码", "share.contextMenu.embedCodePanelTitle": "嵌入代码", "share.contextMenu.embedCodeTab": "嵌入", - "share.contextMenu.exportCodeTab": "导出", "share.contextMenu.permalinkPanelTitle": "获取链接", "share.contextMenu.permalinksLabel": "获取链接", "share.contextMenu.permalinksTab": "链接", @@ -7943,17 +7948,9 @@ "share.embed.helpText": "将此 {objectType} 嵌入到其他网页。", "share.embed.publicUrlOptionsSwitch.label": "允许公众访问", "share.embed.publicUrlOptionsSwitch.tooltip": "启用公众访问将生成一个可共享 URL,以便在不提示登录的情况下进行匿名访问。", - "share.export.generateButtonLabel": "导出文件", - "share.export.helpText": "为此可视化选择您要导出的文件类型。", - "share.fileType": "文件类型", "share.link.copied": "已复制链接", "share.link.copyLinkButton": "复制链接", - "share.link.warning.title": "未保存的更改", - "share.modalContent.copyUrlButtonLabel": "复制 Post URL", - "share.postURLWatcherMessage": "复制此 POST URL 以从 Kibana 外部或从 Watcher 调用生成。", - "share.postURLWatcherMessage.unsavedChanges": "如果升级 Kibana,URL 可能会发生更改。", - "share.screenCapturePanelContent.optimizeForPrintingHelpText": "使用多页,每页最多显示 2 个可视化", - "share.screenCapturePanelContent.optimizeForPrintingLabel": "用于打印", + "share.exportFlyoutContent.optimizeForPrinting.helpText": "使用多页,每页最多显示 2 个可视化", "share.urlPanel.canNotShareAsSavedObjectHelpText": "要作为已保存对象共享,请保存 {objectType}。", "share.urlPanel.copyIframeCodeButtonLabel": "复制 iFrame 代码", "share.urlPanel.copyLinkButtonLabel": "复制链接", @@ -10003,6 +10000,12 @@ "visualizations.embeddable.tsdbRollupWarning": "可视化使用的函数不受汇总/打包数据支持。请选择其他函数,或更改时间范围。", "visualizations.experimentalVisInfoText": "在未来版本中可能会更改或完全移除。Elastic 将努力修复任何问题,但处于技术预览状态的功能不受正式 GA 功能支持 SLA 的约束。如欲提供反馈,请在 {githubLink} 中创建问题。", "visualizations.experimentalVisInfoTitle": "此功能处于技术预览状态。", + "visualizations.exports.imageReports.warning.title": "保存されていない変更", + "visualizations.exports.imageReports.postURLWatcherMessage.unsavedChanges": "POST URLをコピー", + "visualizations.exports.csvReports.warning.title": "保存されていない変更", + "visualizations.exports.csvReports.postURLWatcherMessage.unsavedChanges": "POST URLをコピー", + "visualizations.exports.pdfReports.warning.title": "保存されていない変更", + "visualizations.exports.pdfReports.postURLWatcherMessage.unsavedChanges": "POST URLをコピー", "visualizations.fallbackDataView.label": "未找到 {type}", "visualizations.function.findAccessorOrFail.error.accessor": "提供的列名称或索引无效:{accessor}", "visualizations.function.range.from.help": "范围起始", diff --git a/x-pack/platform/plugins/shared/lens/public/app_plugin/csv_download_provider/csv_download_provider.tsx b/x-pack/platform/plugins/shared/lens/public/app_plugin/csv_download_provider/csv_download_provider.tsx index c1fcfb7301efa..1faf9bbc6d88e 100644 --- a/x-pack/platform/plugins/shared/lens/public/app_plugin/csv_download_provider/csv_download_provider.tsx +++ b/x-pack/platform/plugins/shared/lens/public/app_plugin/csv_download_provider/csv_download_provider.tsx @@ -134,6 +134,7 @@ export const downloadCsvLensShareProvider = ({ }); return { + id: 'csvDownloadLens', name: panelTitle, icon: 'document', sortOrder: 1, @@ -142,7 +143,6 @@ export const downloadCsvLensShareProvider = ({ supportedLayoutOptions: ['print'], requiresSavedState: false, generateAssetExport: downloadCSVHandler, - generateAssetURIValue: () => '', warnings: getWarnings(datatables), ...(atLeastGold() ? { @@ -157,7 +157,7 @@ export const downloadCsvLensShareProvider = ({ defaultMessage="Download the data displayed in the visualization." /> ), - generateExportButton: ( + generateExportButtonLabel: ( ), }), diff --git a/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_top_nav.tsx index 160b4459b1931..93acd04161d92 100644 --- a/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_top_nav.tsx @@ -216,11 +216,30 @@ function getLensTopNavConfig(options: { disableButton: false, }); + if (actions.export.visible) { + topNavMenu.push({ + label: i18n.translate('xpack.lens.app.shareTitle', { + defaultMessage: 'Export', + }), + iconType: 'download', + iconOnly: true, + run: actions.export.execute, + testId: 'lnsApp_exportButton', + description: i18n.translate('xpack.lens.app.shareTitleAria', { + defaultMessage: 'Export visualization', + }), + disableButton: !actions.export.enabled, + tooltip: actions.export.tooltip, + }); + } + if (actions.share.visible) { topNavMenu.push({ label: i18n.translate('xpack.lens.app.shareTitle', { defaultMessage: 'Share', }), + iconType: 'share', + iconOnly: true, run: actions.share.execute, testId: 'lnsApp_shareButton', description: i18n.translate('xpack.lens.app.shareTitleAria', { @@ -566,6 +585,189 @@ export const LensTopNavMenu = ({ const shareUrlEnabled = Boolean(application.capabilities.visualize.createShortUrl && hasData); const showShareMenu = csvEnabled || shareUrlEnabled; + + const shareExecutor = async (anchorElement: HTMLElement, asExport?: boolean) => { + if (!share) { + return; + } + + if (visualization.activeId == null || !visualizationMap[visualization.activeId]) { + return; + } + + const activeVisualization = visualizationMap[visualization.activeId]; + + const configuration: ShareableConfiguration = { + filters, + query, + activeDatasourceId, + datasourceStates, + datasourceMap, + visualizationMap, + visualization, + currentDoc, + adHocDataViews: adHocDataViews.map((dataView) => dataView.toSpec()), + }; + + const { shareURL: shareLocatorParams, reporting: reportingLocatorParams } = getLocatorParams( + data, + configuration, + isCurrentStateDirty + ); + + const datasourceLayers = getDatasourceLayers( + datasourceStates, + datasourceMap, + dataViews.indexPatterns + ); + + const exportDatatables = + activeVisualization.getExportDatatables?.( + visualization.state, + datasourceLayers, + activeData + ) ?? []; + const datatables = + exportDatatables.length > 0 ? exportDatatables : Object.values(activeData ?? {}); + const sharingData = { + datatables, + csvEnabled, + reportingDisabled: !csvEnabled, + title: title || defaultLensTitle, + locatorParams: { + id: LENS_APP_LOCATOR, + params: reportingLocatorParams, + }, + layout: { + dimensions: + activeVisualization.getReportingLayout?.(visualization.state) ?? + DEFAULT_LENS_LAYOUT_DIMENSIONS, + }, + }; + + share.toggleShareContextMenu({ + asExport, + anchorElement, + allowShortUrl: false, + objectId: currentDoc?.savedObjectId, + objectType: 'lens', + objectTypeMeta: { + title: i18n.translate('xpack.lens.app.shareModal.title', { + defaultMessage: 'Share this Lens visualization', + }), + config: { + link: { + draftModeCallOut: ( + + } + > + + + ), + delegatedShareUrlHandler: async () => { + const { shareableUrl, savedObjectURL } = getShareURL( + shortUrlService, + shareLocatorParams, + { application, data }, + configuration, + shareUrlEnabled, + isCurrentStateDirty + ); + + return !currentDoc?.savedObjectId ? (await shareableUrl)! : savedObjectURL.href; + }, + // disable the menu if both shortURL permission and the visualization has not been saved + // TODO: improve here the disabling state with more specific checks + disabled: Boolean(!shareUrlEnabled && !currentDoc?.savedObjectId), + }, + embed: { + disabled: true, + showPublicUrlSwitch: () => false, + }, + integration: { + export: { + csvDownloadLens: { + draftModeCallOut: ( + + {i18n.translate( + 'xpack.lens.app.exports.csvDownloadLens.postURLWatcherMessage.unsavedChanges', + { + defaultMessage: + 'The copied link resolves to the current state of this visualization. To get a permanent link, make sure to save your Lens visualization first.', + } + )} + + ), + }, + imageReports: { + draftModeCallOut: ( + + {i18n.translate( + 'xpack.lens.app.exports.imageReports.postURLWatcherMessage.unsavedChanges', + { + defaultMessage: + 'The copied link resolves to the current state of this visualization. To get a permanent link, make sure to save your Lens visualization first.', + } + )} + + ), + }, + pdfReports: { + draftModeCallOut: ( + + {i18n.translate( + 'xpack.lens.app.exports.pdfReports.postURLWatcherMessage.unsavedChanges', + { + defaultMessage: + 'The copied link resolves to the current state of this visualization. To get a permanent link, make sure to save your Lens visualization first.', + } + )} + + ), + }, + }, + }, + }, + }, + sharingData, + // only want to know about changes when savedObjectURL.href + isDirty: isCurrentStateDirty || !currentDoc?.savedObjectId, + onClose: () => { + anchorElement?.focus(); + }, + }); + }; + const baseMenuEntries = getLensTopNavConfig({ isByValueMode: getIsByValueMode(), savingToLibraryPermitted, @@ -577,6 +779,19 @@ export const LensTopNavMenu = ({ contextFromEmbeddable, actions: { inspect: { visible: true, execute: () => lensInspector.inspect({ title }) }, + export: { + // Only show the export button if the current user meets the requirements for at least one registered export integration + visible: Boolean(share?.availableIntegrations('lens', 'export')?.length), + enabled: showShareMenu, + tooltip: () => { + if (!showShareMenu) { + return i18n.translate('xpack.lens.app.exportButtonDisabledWarning', { + defaultMessage: 'The visualization has no data to export.', + }); + } + }, + execute: (anchorElement) => shareExecutor(anchorElement, true), + }, share: { visible: true, enabled: showShareMenu, @@ -587,121 +802,7 @@ export const LensTopNavMenu = ({ }); } }, - execute: async (anchorElement) => { - if (!share) { - return; - } - - if (visualization.activeId == null || !visualizationMap[visualization.activeId]) { - return; - } - - const activeVisualization = visualizationMap[visualization.activeId]; - - const configuration: ShareableConfiguration = { - filters, - query, - activeDatasourceId, - datasourceStates, - datasourceMap, - visualizationMap, - visualization, - currentDoc, - adHocDataViews: adHocDataViews.map((dataView) => dataView.toSpec()), - }; - - const { shareURL: shareLocatorParams, reporting: reportingLocatorParams } = - getLocatorParams(data, configuration, isCurrentStateDirty); - - const datasourceLayers = getDatasourceLayers( - datasourceStates, - datasourceMap, - dataViews.indexPatterns - ); - - const exportDatatables = - activeVisualization.getExportDatatables?.( - visualization.state, - datasourceLayers, - activeData - ) ?? []; - const datatables = - exportDatatables.length > 0 ? exportDatatables : Object.values(activeData ?? {}); - const sharingData = { - datatables, - csvEnabled, - reportingDisabled: !csvEnabled, - title: title || defaultLensTitle, - locatorParams: { - id: LENS_APP_LOCATOR, - params: reportingLocatorParams, - }, - layout: { - dimensions: - activeVisualization.getReportingLayout?.(visualization.state) ?? - DEFAULT_LENS_LAYOUT_DIMENSIONS, - }, - }; - - share.toggleShareContextMenu({ - anchorElement, - allowShortUrl: false, - objectId: currentDoc?.savedObjectId, - objectType: 'lens', - objectTypeMeta: { - title: i18n.translate('xpack.lens.app.shareModal.title', { - defaultMessage: 'Share this Lens visualization', - }), - config: { - link: { - draftModeCallOut: ( - - } - > - - - ), - delegatedShareUrlHandler: async () => { - const { shareableUrl, savedObjectURL } = getShareURL( - shortUrlService, - shareLocatorParams, - { application, data }, - configuration, - shareUrlEnabled, - isCurrentStateDirty - ); - - return !currentDoc?.savedObjectId - ? (await shareableUrl)! - : savedObjectURL.href; - }, - // disable the menu if both shortURL permission and the visualization has not been saved - // TODO: improve here the disabling state with more specific checks - disabled: Boolean(!shareUrlEnabled && !currentDoc?.savedObjectId), - }, - embed: { - disabled: true, - showPublicUrlSwitch: () => false, - }, - }, - }, - sharingData, - // only want to know about changes when savedObjectURL.href - isDirty: isCurrentStateDirty || !currentDoc?.savedObjectId, - onClose: () => { - anchorElement?.focus(); - }, - }); - }, + execute: shareExecutor, }, saveAndReturn: { visible: showSaveAndReturn, diff --git a/x-pack/platform/plugins/shared/lens/public/app_plugin/types.ts b/x-pack/platform/plugins/shared/lens/public/app_plugin/types.ts index 4791dc89d446f..0b18130cf8a36 100644 --- a/x-pack/platform/plugins/shared/lens/public/app_plugin/types.ts +++ b/x-pack/platform/plugins/shared/lens/public/app_plugin/types.ts @@ -188,6 +188,7 @@ type AvailableTopNavActions = | 'goBack' | 'cancel' | 'share' + | 'export' | 'getUnderlyingDataUrl' | 'openSettings'; export type LensTopNavActions = Record; diff --git a/x-pack/platform/test/functional/apps/security/secure_roles_perm.ts b/x-pack/platform/test/functional/apps/security/secure_roles_perm.ts index d87f219a4ec70..8ae870165ab6a 100644 --- a/x-pack/platform/test/functional/apps/security/secure_roles_perm.ts +++ b/x-pack/platform/test/functional/apps/security/secure_roles_perm.ts @@ -18,6 +18,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'common', 'share', 'header', + 'exports', ]); const log = getService('log'); const esArchiver = getService('esArchiver'); @@ -80,12 +81,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.missingOrFail('users'); }); - it('Kibana User navigating to Discover sees the generate CSV button', async function () { + it('Kibana User navigating to Discover sees the export button', async function () { await PageObjects.common.navigateToApp('discover'); await PageObjects.discover.loadSavedSearch('A Saved Search'); log.debug('click Top Nav Share button'); - await PageObjects.share.clickShareTopNavButton(); - await testSubjects.existOrFail('export'); + await PageObjects.exports.exportButtonExists(); }); after(async function () { diff --git a/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts b/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts index 60a7f754933de..c27bf2cc91b20 100644 --- a/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts +++ b/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts @@ -16,7 +16,7 @@ export default function ({ getService, updateBaselines, }: FtrProviderContext & { updateBaselines: boolean }) { - const { reporting, dashboard, share } = getPageObjects(['reporting', 'dashboard', 'share']); + const { reporting, dashboard, exports } = getPageObjects(['reporting', 'dashboard', 'exports']); const esArchiver = getService('esArchiver'); const security = getService('security'); const browser = getService('browser'); @@ -72,8 +72,8 @@ export default function ({ 'reporting_user', // NOTE: the built-in role granting full reporting access is deprecated. See the xpack.reporting.roles.enabled setting ]); }); + after('clean up archives', async () => { - await share.closeShareModal(); await unloadEcommerce(); await es.deleteByQuery({ index: '.reporting-*', @@ -85,19 +85,19 @@ export default function ({ describe('Print PDF button', () => { afterEach(async () => { - await share.closeShareModal(); + await exports.closeExportFlyout(); }); it('is available if new', async () => { await dashboard.navigateToApp(); await dashboard.clickNewDashboard(); - await reporting.openExportTab(); + await reporting.selectExportItem('PDF'); expect(await reporting.isGenerateReportButtonDisabled()).to.be(null); }); it('is available when saved', async () => { await dashboard.saveDashboard('My PDF Dashboard'); - await reporting.openExportTab(); + await reporting.selectExportItem('PDF'); expect(await reporting.isGenerateReportButtonDisabled()).to.be(null); }); }); @@ -116,7 +116,7 @@ export default function ({ this.timeout(300000); await dashboard.navigateToApp(); await dashboard.loadSavedDashboard('Ecom Dashboard'); - await reporting.openExportTab(); + await reporting.selectExportItem('PDF'); await reporting.checkUsePrintLayout(); await reporting.clickGenerateReportButton(); @@ -125,7 +125,7 @@ export default function ({ expect(res.status).to.equal(200); expect(res.get('content-type')).to.equal('application/pdf'); - await share.closeShareModal(); + await exports.closeExportFlyout(); }); it('provides a button to copy POST URL', async () => { @@ -136,11 +136,12 @@ export default function ({ await dashboard.navigateToApp(); await dashboard.loadSavedDashboard('Ecom Dashboard'); - await reporting.openExportTab(); + await reporting.selectExportItem('PDF'); await reporting.checkUsePrintLayout(); - await testSubjects.click('shareReportingCopyURL'); - const postUrl = await browser.getClipboardValue(); + await reporting.copyReportingPOSTURLValueToClipboard(); + + const postUrl = decodeURIComponent(await browser.getClipboardValue()); expect(postUrl).to.contain('printablePdfV2'); const [, jobParams] = postUrl.split('jobParams='); @@ -168,16 +169,14 @@ export default function ({ it('is available if new', async () => { await dashboard.navigateToApp(); await dashboard.clickNewDashboard(); - await reporting.openExportTab(); - await testSubjects.click('pngV2-radioOption'); + await reporting.selectExportItem('PNG'); expect(await reporting.isGenerateReportButtonDisabled()).to.be(null); - await share.closeShareModal(); + await exports.closeExportFlyout(); }); it('is available when saved', async () => { await dashboard.saveDashboard('My PNG Dash'); - await reporting.openExportTab(); - await testSubjects.click('pngV2-radioOption'); + await reporting.selectExportItem('PNG'); expect(await reporting.isGenerateReportButtonDisabled()).to.be(null); await (await testSubjects.find('kibanaChrome')).clickMouseButton(); // close popover }); @@ -190,11 +189,10 @@ export default function ({ await dashboard.navigateToApp(); await dashboard.loadSavedDashboard('Ecom Dashboard'); - await reporting.openExportTab(); - await testSubjects.click('pngV2-radioOption'); - await testSubjects.click('shareReportingCopyURL'); + await reporting.selectExportItem('PNG'); + await reporting.copyReportingPOSTURLValueToClipboard(); - const postUrl = await browser.getClipboardValue(); + const postUrl = decodeURIComponent(await browser.getClipboardValue()); expect(postUrl).to.contain('pngV2'); const [, jobParams] = postUrl.split('jobParams='); @@ -225,12 +223,12 @@ export default function ({ this.timeout(300000); await dashboard.navigateToApp(); await dashboard.loadSavedDashboard('Ecom Dashboard'); - await reporting.openExportTab(); + await reporting.selectExportItem('PDF'); await reporting.clickGenerateReportButton(); const url = await reporting.getReportURL(60000); const res = await reporting.getResponse(url ?? ''); - await share.closeShareModal(); + await exports.closeExportFlyout(); expect(res.status).to.equal(200); expect(res.get('content-type')).to.equal('application/pdf'); @@ -245,10 +243,10 @@ export default function ({ await dashboard.navigateToApp(); await dashboard.loadSavedDashboard('Ecom Dashboard'); - await reporting.openExportTab(); - await testSubjects.click('shareReportingCopyURL'); + await reporting.selectExportItem('PDF'); + await reporting.copyReportingPOSTURLValueToClipboard(); - const postUrl = await browser.getClipboardValue(); + const postUrl = decodeURIComponent(await browser.getClipboardValue()); expect(postUrl).to.contain('printablePdfV2'); const [, jobParams] = postUrl.split('jobParams='); @@ -283,8 +281,7 @@ export default function ({ await dashboard.navigateToApp(); await dashboard.loadSavedDashboard('[K7.6-eCommerce] Revenue Dashboard'); - await reporting.openExportTab(); - await testSubjects.click('pngV2-radioOption'); + await reporting.selectExportItem('PNG'); await reporting.forceSharedItemsContainerSize({ width: 1405 }); await reporting.clickGenerateReportButton(); await reporting.removeForceSharedItemsContainerSize(); diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts index b7496a500c386..e1c1d61c1dde0 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts @@ -22,17 +22,27 @@ export default function (ctx: FtrProviderContext) { const monacoEditor = getService('monacoEditor'); const securityService = getService('security'); const globalNav = getService('globalNav'); - const { common, error, discover, timePicker, security, share, header, unifiedFieldList } = - getPageObjects([ - 'common', - 'error', - 'discover', - 'timePicker', - 'security', - 'share', - 'header', - 'unifiedFieldList', - ]); + const { + common, + error, + discover, + timePicker, + security, + share, + header, + unifiedFieldList, + exports, + } = getPageObjects([ + 'common', + 'error', + 'discover', + 'timePicker', + 'security', + 'share', + 'header', + 'unifiedFieldList', + 'exports', + ]); const testSubjects = getService('testSubjects'); const appsMenu = getService('appsMenu'); const kibanaServer = getService('kibanaServer'); @@ -131,10 +141,9 @@ export default function (ctx: FtrProviderContext) { }); it('shows CSV reports', async () => { - await share.clickShareTopNavButton(); - await share.clickTab('Export'); + await exports.clickExportTopNavButton(); await testSubjects.existOrFail('generateReportButton'); - await share.closeShareModal(); + await exports.closeExportFlyout(); }); savedQuerySecurityUtils.shouldAllowSavingQueries(); diff --git a/x-pack/test/functional/apps/discover/reporting.ts b/x-pack/test/functional/apps/discover/reporting.ts index b4d5acc0d6d66..b678cfb277357 100644 --- a/x-pack/test/functional/apps/discover/reporting.ts +++ b/x-pack/test/functional/apps/discover/reporting.ts @@ -18,12 +18,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const browser = getService('browser'); const retry = getService('retry'); - const { reporting, common, discover, timePicker, share, header } = getPageObjects([ + const { reporting, common, discover, timePicker, header, exports } = getPageObjects([ 'reporting', 'common', 'discover', 'timePicker', 'share', + 'exports', 'header', ]); const monacoEditor = getService('monacoEditor'); @@ -106,7 +107,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // close any open notification toasts await toasts.dismissAll(); - await reporting.openExportTab(); + await exports.clickExportTopNavButton(); await reporting.clickGenerateReportButton(); const url = await reporting.getReportURL(timeout); @@ -119,11 +120,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const getReportPostUrl = async () => { // click 'Copy POST URL' - await share.clickShareTopNavButton(); - await reporting.openExportTab(); - const copyButton = await testSubjects.find('shareReportingCopyURL'); + await exports.clickExportTopNavButton(); + await reporting.copyReportingPOSTURLValueToClipboard(); - return decodeURIComponent((await copyButton.getAttribute('data-share-url')) ?? ''); + const clipboardValue = decodeURIComponent(await browser.getClipboardValue()); + + await exports.closeExportFlyout(); + + return clipboardValue; }; describe('Discover CSV Export', () => { @@ -141,16 +145,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('is available if new', async () => { - await reporting.openExportTab(); + await reporting.openExportPopover(); expect(await reporting.isGenerateReportButtonDisabled()).to.be(null); - await share.closeShareModal(); + await exports.closeExportFlyout(); }); it('becomes available when saved', async () => { await discover.saveSearch('my search - expectEnabledGenerateReportButton'); - await reporting.openExportTab(); + await reporting.openExportPopover(); expect(await reporting.isGenerateReportButtonDisabled()).to.be(null); - await share.closeShareModal(); + await exports.closeExportFlyout(); }); }); @@ -186,13 +190,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // get shared URL value const sharedURL = await browser.getCurrentUrl(); - // click 'Copy POST URL' - await share.clickShareTopNavButton(); - await reporting.openExportTab(); - const copyButton = await testSubjects.find('shareReportingCopyURL'); - const reportURL = decodeURIComponent( - (await copyButton.getAttribute('data-share-url')) ?? '' - ); + const reportURL = await getReportPostUrl(); // get number of filters in URLs const timeFiltersNumberInReportURL = diff --git a/x-pack/test/functional/apps/lens/group1/ad_hoc_data_view.ts b/x-pack/test/functional/apps/lens/group1/ad_hoc_data_view.ts index b7c4cbdddd5fa..5adceca310289 100644 --- a/x-pack/test/functional/apps/lens/group1/ad_hoc_data_view.ts +++ b/x-pack/test/functional/apps/lens/group1/ad_hoc_data_view.ts @@ -190,7 +190,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should be possible to download a visualization with adhoc dataViews', async () => { await lens.setCSVDownloadDebugFlag(true); - await lens.openCSVDownloadShare(); + await lens.openCSVDownloadExport(); const csv = await lens.getCSVContent(); expect(csv).to.be.ok(); diff --git a/x-pack/test/functional/apps/lens/group4/share.ts b/x-pack/test/functional/apps/lens/group4/share.ts index 4d01b3cf65c91..8973495ed9de4 100644 --- a/x-pack/test/functional/apps/lens/group4/share.ts +++ b/x-pack/test/functional/apps/lens/group4/share.ts @@ -21,6 +21,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { afterEach(async () => { await lens.closeShareModal(); + await lens.closeExportFlyout(); }); after(async () => { @@ -57,7 +58,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should enable both download and URL sharing for valid configuration', async () => { await lens.clickShareModal(); - expect(await lens.isShareActionEnabled('export')); + expect(await lens.isExportActionEnabled()).to.eql(true); expect(await lens.isShareActionEnabled('link')); }); @@ -83,7 +84,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should be able to download CSV data of the current visualization', async () => { await lens.setCSVDownloadDebugFlag(true); - await lens.openCSVDownloadShare(); + await lens.openCSVDownloadExport(); const csv = await lens.getCSVContent(); expect(csv).to.be.ok(); @@ -105,7 +106,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { field: 'bytes', }); - await lens.openCSVDownloadShare(); + await lens.openCSVDownloadExport(); const csv = await lens.getCSVContent(); expect(csv).to.be.ok(); diff --git a/x-pack/test/functional/apps/lens/group6/lens_reporting.ts b/x-pack/test/functional/apps/lens/group6/lens_reporting.ts index 4b2a182df741a..d1cc13d1054db 100644 --- a/x-pack/test/functional/apps/lens/group6/lens_reporting.ts +++ b/x-pack/test/functional/apps/lens/group6/lens_reporting.ts @@ -9,13 +9,14 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const { common, dashboard, lens, reporting, timePicker, visualize } = getPageObjects([ + const { common, dashboard, lens, reporting, timePicker, visualize, exports } = getPageObjects([ 'common', 'dashboard', 'lens', 'reporting', 'timePicker', 'visualize', + 'exports', ]); const es = getService('es'); const testSubjects = getService('testSubjects'); @@ -53,23 +54,21 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); afterEach(async () => { - if (await testSubjects.exists('shareContextModal')) { - await lens.closeShareModal(); + if (await testSubjects.exists('toastCloseButton')) { + await testSubjects.click('toastCloseButton'); } + + await lens.closeExportFlyout(); }); it('should not cause PDF reports to fail', async () => { await dashboard.navigateToApp(); await listingTable.clickItemLink('dashboard', 'Lens reportz'); - await reporting.openExportTab(); + await reporting.selectExportItem('PDF'); await reporting.clickGenerateReportButton(); - await lens.closeShareModal(); + await lens.closeExportFlyout(); const url = await reporting.getReportURL(60000); expect(url).to.be.ok(); - if (await testSubjects.exists('toastCloseButton')) { - await testSubjects.click('toastCloseButton'); - } - await lens.closeShareModal(); }); for (const type of ['PNG', 'PDF'] as const) { @@ -93,11 +92,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // now remove a dimension to make it incomplete await lens.removeDimension('lnsXY_yDimensionPanel'); - // open the share menu and check that reporting is disabled - await lens.clickShareModal(); - expect(await testSubjects.exists('export')).to.be(false); - await lens.closeShareModal(); + expect(await lens.isPopoverItemEnabled(type)).to.eql(false); }); it(`should be able to download report of the current visualization`, async () => { @@ -108,13 +104,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { field: 'bytes', }); - await lens.openReportingShare(type); + await lens.clickPopoverItem(type); await reporting.clickGenerateReportButton(); const url = await reporting.getReportURL(60000); - await lens.closeShareModal(); - expect(url).to.be.ok(); if (await testSubjects.exists('toastCloseButton')) { await testSubjects.click('toastCloseButton'); @@ -123,17 +117,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it(`should enable curl reporting if the visualization is saved`, async () => { await lens.save(`ASavedVisualizationToShareIn${type}`); + await lens.clickPopoverItem(type); - await lens.openReportingShare(type); - await testSubjects.existOrFail('shareReportingCopyURL'); - expect(await testSubjects.getVisibleText('shareReportingCopyURL')).to.eql( - 'Copy Post URL' - ); - await lens.closeShareModal(); + const copyButton = await exports.getExportAssetTextButton(); + + if (copyButton) { + expect(await copyButton.getAttribute('aria-label')).to.eql('Copy export asset value'); + } }); it(`should produce a valid URL for reporting`, async () => { - await lens.openReportingShare(type); + await lens.clickPopoverItem(type); await reporting.clickGenerateReportButton(); await reporting.getReportURL(60000); if (await testSubjects.exists('toastCloseButton')) { diff --git a/x-pack/test/functional/apps/maps/group3/reports/index.ts b/x-pack/test/functional/apps/maps/group3/reports/index.ts index dbef00501aa70..e987ad03e8469 100644 --- a/x-pack/test/functional/apps/maps/group3/reports/index.ts +++ b/x-pack/test/functional/apps/maps/group3/reports/index.ts @@ -12,7 +12,6 @@ const REPORTS_FOLDER = __dirname; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { reporting, dashboard } = getPageObjects(['reporting', 'dashboard']); - const testSubjects = getService('testSubjects'); const browser = getService('browser'); const config = getService('config'); const log = getService('log'); @@ -60,8 +59,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await dashboard.navigateToApp(); await dashboard.loadSavedDashboard('Ecommerce Map'); - await reporting.openExportTab(); - await testSubjects.click('pngV2-radioOption'); + await reporting.selectExportItem('PNG'); await reporting.clickGenerateReportButton(); const percentDiff = await measurePngDifference('geo_map_report'); @@ -73,8 +71,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('PNG file matches the baseline image, using embeddable example', async function () { await dashboard.navigateToApp(); await dashboard.loadSavedDashboard('map embeddable example'); - await reporting.openExportTab(); - await testSubjects.click('pngV2-radioOption'); + await reporting.selectExportItem('PNG'); await reporting.clickGenerateReportButton(); const percentDiff = await measurePngDifference('example_map_report'); diff --git a/x-pack/test/functional/apps/visualize/reporting.ts b/x-pack/test/functional/apps/visualize/reporting.ts index 7fbea5ac6ba5d..e0e0b51ed98cd 100644 --- a/x-pack/test/functional/apps/visualize/reporting.ts +++ b/x-pack/test/functional/apps/visualize/reporting.ts @@ -19,14 +19,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const config = getService('config'); const kibanaServer = getService('kibanaServer'); const png = getService('png'); - const testSubjects = getService('testSubjects'); - const { reporting, common, visualize, visEditor, share } = getPageObjects([ + const { reporting, common, visualize, visEditor, exports } = getPageObjects([ 'reporting', 'common', 'visualize', 'visEditor', - 'share', + 'exports', ]); describe('Visualize Reporting Screenshots', function () { @@ -69,7 +68,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); afterEach(async () => { - await share.closeShareModal(); + await exports.closeExportFlyout(); }); it('is available if new', async () => { @@ -78,7 +77,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await visualize.clickAggBasedVisualizations(); await visualize.clickAreaChart(); await visualize.clickNewSearch('ecommerce'); - await reporting.openExportTab(); + await reporting.selectExportItem('PDF'); expect(await reporting.isGenerateReportButtonDisabled()).to.be(null); }); @@ -87,7 +86,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await visEditor.selectAggregation('Date Histogram'); await visEditor.clickGo(); await visualize.saveVisualization('my viz'); - await reporting.openExportTab(); + await reporting.selectExportItem('PDF'); expect(await reporting.isGenerateReportButtonDisabled()).to.be(null); }); }); @@ -120,7 +119,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); afterEach(async () => { - await share.closeShareModal(); + await exports.closeExportFlyout(); }); // FAILING ARTIFACTS SNAPSHOT: https://github.com/elastic/kibana/issues/189590 @@ -131,8 +130,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); log.debug('open png reporting panel'); - await reporting.openExportTab(); - await testSubjects.click('pngV2-radioOption'); + await reporting.selectExportItem('PNG'); log.debug('click generate report button'); await reporting.clickGenerateReportButton(); diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 7897ff2f19119..13b19c4ac6045 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -36,7 +36,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont const queryBar = getService('queryBar'); const dataViews = getService('dataViews'); - const { common, header, timePicker, dashboard, timeToVisualize, unifiedSearch, share } = + const { common, header, timePicker, dashboard, timeToVisualize, unifiedSearch, share, exports } = getPageObjects([ 'common', 'header', @@ -45,6 +45,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont 'timeToVisualize', 'unifiedSearch', 'share', + 'exports', ]); return logWrapper('lensPage', log, { @@ -1925,11 +1926,19 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont return await testSubjects.click('lnsApp_shareButton'); }, + async clickExportButton() { + return await testSubjects.click('lnsApp_exportButton'); + }, + async isShareable() { return await testSubjects.isEnabled('lnsApp_shareButton'); }, - async isShareActionEnabled(action: 'export' | 'link') { + isExportActionEnabled() { + return testSubjects.isEnabled('lnsApp_exportButton'); + }, + + async isShareActionEnabled(action: 'link') { switch (action) { case 'link': return await testSubjects.isEnabled('link'); @@ -1938,7 +1947,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont } }, - async ensureShareMenuIsOpen(action: 'export' | 'link') { + async ensureShareMenuIsOpen(action: 'link') { await this.clickShareModal(); if (!(await testSubjects.exists('shareContextModal'))) { @@ -1959,6 +1968,30 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont return share.closeShareModal(); }, + closeExportFlyout() { + return exports.closeExportFlyout(); + }, + + async isPopoverItemEnabled(label: string) { + await this.clickExportButton(); + + return await exports.isPopoverItemEnabled(label); + }, + + async clickPopoverItem(label: string) { + log.debug(`clickPopoverItem label: ${label}`); + + await retry.waitFor('ascertain that export popover is open', async () => { + const isExportPopoverOpen = await exports.isExportPopoverOpen(); + if (!isExportPopoverOpen) { + await this.clickExportButton(); + } + return isExportPopoverOpen; + }); + + await testSubjects.click(`exportMenuItem-${label}`); + }, + async getUrl() { await this.ensureShareMenuIsOpen('link'); const url = await share.getSharedUrl(); @@ -1972,10 +2005,9 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont return url; }, - async openCSVDownloadShare() { - await this.ensureShareMenuIsOpen('export'); - await testSubjects.click('export'); - await testSubjects.click('lens_csv-radioOption'); + async openCSVDownloadExport() { + await this.clickExportButton(); + await exports.clickPopoverItem('CSV'); }, async setCSVDownloadDebugFlag(value: boolean = true) { @@ -1985,14 +2017,8 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont }, async openReportingShare(type: 'PNG' | 'PDF') { - await this.ensureShareMenuIsOpen(`export`); - await testSubjects.click(`export`); - if (type === 'PDF') { - return await testSubjects.click('printablePdfV2-radioOption'); - } - if (type === 'PNG') { - return await testSubjects.click('pngV2-radioOption'); - } + await this.clickExportButton(); + await exports.clickPopoverItem(type); }, async getCSVContent() { diff --git a/x-pack/test/functional/page_objects/reporting_page.ts b/x-pack/test/functional/page_objects/reporting_page.ts index 2141869635c5a..da334b4a2fbce 100644 --- a/x-pack/test/functional/page_objects/reporting_page.ts +++ b/x-pack/test/functional/page_objects/reporting_page.ts @@ -26,7 +26,7 @@ export class ReportingPageObject extends FtrService { private readonly security = this.ctx.getService('security'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly find = this.ctx.getService('find'); - private readonly share = this.ctx.getPageObject('share'); + private readonly exports = this.ctx.getPageObject('exports'); private readonly timePicker = this.ctx.getPageObject('timePicker'); async forceSharedItemsContainerSize({ width }: { width: number }) { @@ -131,9 +131,13 @@ export class ReportingPageObject extends FtrService { await this.testSubjects.waitForDeleted(menuPanel); } - async openExportTab() { - this.log.debug('open export modal'); - await this.share.clickTab('Export'); + async openExportPopover() { + this.log.debug('open export popover'); + await this.exports.clickExportTopNavButton(); + } + + async selectExportItem(label: string) { + await this.exports.clickPopoverItem(label); } async getQueueReportError() { @@ -253,4 +257,8 @@ export class ReportingPageObject extends FtrService { this.log.debug(`baselineReportPath (${fullPath})`); return fullPath; } + + async copyReportingPOSTURLValueToClipboard() { + await this.exports.copyExportAssetText(); + } } diff --git a/x-pack/test/reporting_functional/reporting_and_security/management.ts b/x-pack/test/reporting_functional/reporting_and_security/management.ts index e1d124ede931e..6d61ec6541cbe 100644 --- a/x-pack/test/reporting_functional/reporting_and_security/management.ts +++ b/x-pack/test/reporting_functional/reporting_and_security/management.ts @@ -41,7 +41,7 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.loadSavedDashboard(dashboardTitle); - await PageObjects.reporting.openExportTab(); + await PageObjects.reporting.selectExportItem('PDF'); await PageObjects.reporting.clickGenerateReportButton(); await PageObjects.common.navigateToApp('reporting'); diff --git a/x-pack/test/reporting_functional/services/scenarios.ts b/x-pack/test/reporting_functional/services/scenarios.ts index aea50c207dc20..b526a1b26dfb1 100644 --- a/x-pack/test/reporting_functional/services/scenarios.ts +++ b/x-pack/test/reporting_functional/services/scenarios.ts @@ -28,6 +28,7 @@ export function createScenarios( 'dashboard', 'discover', 'canvas', + 'exports', ]); const scenariosAPI = createAPIScenarios(context); @@ -101,38 +102,42 @@ export function createScenarios( await testSubjects.existOrFail('csvReportStarted'); /* validate toast panel */ }; const tryDiscoverCsvFail = async () => { - await PageObjects.reporting.openExportTab(); + await PageObjects.reporting.openExportPopover(); await PageObjects.reporting.clickGenerateReportButton(); const queueReportError = await PageObjects.reporting.getQueueReportError(); expect(queueReportError).to.be(true); }; + const tryDiscoverCsvNotAvailable = async () => { - await PageObjects.share.clickShareTopNavButton(); - await testSubjects.missingOrFail('Export'); + expect(await PageObjects.exports.exportButtonExists()).to.be(false); }; + const tryDiscoverCsvSuccess = async () => { - await PageObjects.reporting.openExportTab(); + await PageObjects.reporting.openExportPopover(); expect(await PageObjects.reporting.canReportBeCreated()).to.be(true); }; const tryGeneratePdfFail = async () => { - await PageObjects.reporting.openExportTab(); + await PageObjects.reporting.openExportPopover(); + await PageObjects.reporting.selectExportItem('PDF'); await PageObjects.reporting.clickGenerateReportButton(); const queueReportError = await PageObjects.reporting.getQueueReportError(); expect(queueReportError).to.be(true); }; const tryGeneratePdfNotAvailable = async () => { - await PageObjects.share.clickShareTopNavButton(); - await testSubjects.missingOrFail(`Export`); + await PageObjects.exports.clickExportTopNavButton(); + await testSubjects.missingOrFail(`exportMenuItem-PDF`); }; const tryGeneratePdfSuccess = async () => { - await PageObjects.reporting.openExportTab(); + await PageObjects.reporting.openExportPopover(); + await PageObjects.reporting.selectExportItem('PDF'); expect(await PageObjects.reporting.canReportBeCreated()).to.be(true); }; const tryGeneratePngSuccess = async () => { - await PageObjects.reporting.openExportTab(); - await testSubjects.click('pngV2-radioOption'); + await PageObjects.reporting.openExportPopover(); + await PageObjects.reporting.selectExportItem('PNG'); expect(await PageObjects.reporting.canReportBeCreated()).to.be(true); }; + const tryReportsNotAvailable = async () => { await PageObjects.share.clickShareTopNavButton(); await testSubjects.missingOrFail('Export'); diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts b/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts index ecd5c37e4fbaa..d698e4ea0e5a5 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts @@ -23,7 +23,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'svlCommonPage', 'discover', 'timePicker', - 'share', + 'exports', 'unifiedFieldList', 'timePicker', ]); @@ -39,7 +39,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // close any open notification toasts await toasts.dismissAll(); - await PageObjects.reporting.openExportTab(); + await PageObjects.reporting.openExportPopover(); await PageObjects.reporting.clickGenerateReportButton(); const url = await PageObjects.reporting.getReportURL(timeout); @@ -88,11 +88,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); afterEach(async () => { - await PageObjects.share.closeShareModal(); + await PageObjects.exports.closeExportFlyout(); }); it('is available if new', async () => { - await PageObjects.reporting.openExportTab(); + await PageObjects.reporting.openExportPopover(); expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); }); @@ -101,7 +101,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'my search - expectEnabledGenerateReportButton', true ); - await PageObjects.reporting.openExportTab(); + await PageObjects.reporting.openExportPopover(); expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); }); });