[Reporting] fix dashboard "Copy Post URL" action (#192530)

## Summary

Closes https://github.com/elastic/kibana/issues/191673
Closes https://github.com/elastic/kibana/issues/183566

Fixes the ability for the POST URL used to automate generation of
reports by adding a `generateExportUrl` function to the ShareMenuItemV2
interface. This function returns a dynamic export URL for PDF generation
by using the selected layout option.

Other changes: provides more strictness in type definitions by:
  * splitting the types that define `ShareMenuProvider`:
    * `ShareMenuProviderV2` provides the `getShareMenuItems` function
* `ShareMenuProviderLegacy` provides the `getShareMenuItemsLegacy`
function

### Release note
Fixed an issue with the export options for PNG/PDF reports in a
dashboard.

### Checklist

Delete any items that are not applicable to this PR.

- [x] Use the `generateExportUrl` function inputs to return a POST URL
that is aware of the layout mode (`print` or `preserve_layout`) and
screen dimensions
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Flaky test runner:
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/6986
This commit is contained in:
Tim Sullivan 2024-10-07 16:54:21 -07:00 committed by GitHub
parent 28d6a22263
commit 38407ae6b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 220 additions and 257 deletions

View file

@ -22,7 +22,7 @@ export class ShareDemoPlugin implements Plugin<void, void, SetupDeps, StartDeps>
public setup(core: CoreSetup<StartDeps>, { share }: SetupDeps) { public setup(core: CoreSetup<StartDeps>, { share }: SetupDeps) {
share.register({ share.register({
id: 'demo', id: 'demo',
getShareMenuItems: (context) => [ getShareMenuItemsLegacy: (context) => [
{ {
panel: { panel: {
id: 'demo', id: 'demo',

View file

@ -227,6 +227,9 @@ export class ReportingAPIClient implements IReportingAPI {
}); });
} }
/**
* Adds the browserTimezone and kibana version to report job params
*/
public getDecoratedJobParams<T extends AppParams>(baseParams: T): BaseParams { public getDecoratedJobParams<T extends AppParams>(baseParams: T): BaseParams {
// If the TZ is set to the default "Browser", it will not be useful for // If the TZ is set to the default "Browser", it will not be useful for
// server-side export. We need to derive the timezone and pass it as a param // server-side export. We need to derive the timezone and pass it as a param

View file

@ -11,7 +11,6 @@ import * as Rx from 'rxjs';
import type { ApplicationStart, CoreStart } from '@kbn/core/public'; import type { ApplicationStart, CoreStart } from '@kbn/core/public';
import { ILicense } from '@kbn/licensing-plugin/public'; import { ILicense } from '@kbn/licensing-plugin/public';
import type { LayoutParams } from '@kbn/screenshotting-plugin/common';
import type { ReportingAPIClient } from '../../reporting_api_client'; import type { ReportingAPIClient } from '../../reporting_api_client';
@ -47,13 +46,16 @@ export interface ExportPanelShareOpts {
export interface ReportingSharingData { export interface ReportingSharingData {
title: string; title: string;
layout: LayoutParams;
reportingDisabled?: boolean; reportingDisabled?: boolean;
[key: string]: unknown; locatorParams: {
id: string;
params: unknown;
};
} }
export interface JobParamsProviderOptions { export interface JobParamsProviderOptions {
sharingData: ReportingSharingData; sharingData: ReportingSharingData;
shareableUrl?: string; shareableUrl?: string;
objectType: string; objectType: string;
optimizedForPrinting?: boolean;
} }

View file

@ -16,7 +16,7 @@ import { CSV_JOB_TYPE, CSV_JOB_TYPE_V2 } from '@kbn/reporting-export-types-csv-c
import type { SearchSourceFields } from '@kbn/data-plugin/common'; import type { SearchSourceFields } from '@kbn/data-plugin/common';
import { FormattedMessage, InjectedIntl } from '@kbn/i18n-react'; import { FormattedMessage, InjectedIntl } from '@kbn/i18n-react';
import { ShareContext, ShareMenuItem } from '@kbn/share-plugin/public'; import { ShareContext, ShareMenuItemV2 } from '@kbn/share-plugin/public';
import type { ExportModalShareOpts } from '.'; import type { ExportModalShareOpts } from '.';
import { checkLicense } from '../..'; import { checkLicense } from '../..';
@ -69,7 +69,7 @@ export const reportingCsvShareProvider = ({
}; };
}; };
const shareActions: ShareMenuItem[] = []; const shareActions: ShareMenuItemV2[] = [];
const licenseCheck = checkLicense(license.check('reporting', 'basic')); const licenseCheck = checkLicense(license.check('reporting', 'basic'));
const licenseToolTipContent = licenseCheck.message; const licenseToolTipContent = licenseCheck.message;
@ -177,8 +177,8 @@ export const reportingCsvShareProvider = ({
/> />
), ),
generateExport: generateReportingJobCSV, generateExport: generateReportingJobCSV,
generateExportUrl: () => absoluteUrl,
generateCopyUrl: reportingUrl, generateCopyUrl: reportingUrl,
absoluteUrl,
renderCopyURLButton: true, renderCopyURLButton: true,
}); });
} }

View file

@ -8,31 +8,28 @@
*/ */
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { FormattedMessage, InjectedIntl } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import { toMountPoint } from '@kbn/react-kibana-mount'; import { toMountPoint } from '@kbn/react-kibana-mount';
import { ShareContext, ShareMenuItem, ShareMenuProvider } from '@kbn/share-plugin/public'; import { ShareContext, ShareMenuItemV2, ShareMenuProvider } from '@kbn/share-plugin/public';
import React from 'react'; import React from 'react';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { import { ScreenshotExportOpts } from '@kbn/share-plugin/public/types';
ExportModalShareOpts, import { ExportModalShareOpts, JobParamsProviderOptions, ReportingSharingData } from '.';
ExportPanelShareOpts,
JobParamsProviderOptions,
ReportingSharingData,
} from '.';
import { checkLicense } from '../../license_check'; import { checkLicense } from '../../license_check';
import { ScreenCapturePanelContent } from './screen_capture_panel_content_lazy';
const getJobParams = (opts: JobParamsProviderOptions, type: 'pngV2' | 'printablePdfV2') => () => { const getJobParams = (opts: JobParamsProviderOptions, type: 'pngV2' | 'printablePdfV2') => () => {
const { const {
objectType, objectType,
sharingData: { title, layout, locatorParams }, sharingData: { title, locatorParams },
optimizedForPrinting,
} = opts; } = opts;
const baseParams = { const el = document.querySelector('[data-shared-items-container]');
objectType, const { height, width } = el ? el.getBoundingClientRect() : { height: 768, width: 1024 };
layout, const dimensions = { height, width };
title, const layoutId = optimizedForPrinting ? ('print' as const) : ('preserve_layout' as const);
}; const layout = { id: layoutId, dimensions };
const baseParams = { objectType, layout, title };
if (type === 'printablePdfV2') { if (type === 'printablePdfV2') {
// multi locator for PDF V2 // multi locator for PDF V2
@ -43,154 +40,8 @@ const getJobParams = (opts: JobParamsProviderOptions, type: 'pngV2' | 'printable
}; };
/** /**
* This is used by Canvas * This is used by Dashboard and Visualize apps (sharing modal)
*/ */
export const reportingScreenshotShareProvider = ({
apiClient,
license,
application,
usesUiCapabilities,
startServices$,
}: ExportPanelShareOpts): ShareMenuProvider => {
const getShareMenuItems = ({
objectType,
objectId,
isDirty,
onClose,
shareableUrl,
shareableUrlForSavedObject,
...shareOpts
}: ShareContext) => {
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 [];
}
const isSupportedType = ['dashboard', 'visualization', 'lens'].includes(objectType);
if (!isSupportedType) {
return [];
}
if (objectType === 'dashboard' && !capabilityHasDashboardScreenshotReporting) {
return [];
}
if (
isSupportedType &&
!capabilityHasVisualizeScreenshotReporting &&
!capabilityHasDashboardScreenshotReporting
) {
return [];
}
const { sharingData } = shareOpts as unknown as { sharingData: ReportingSharingData };
const shareActions: ShareMenuItem[] = [];
const pngPanelTitle = i18n.translate('reporting.share.contextMenu.pngReportsButtonLabel', {
defaultMessage: 'PNG Reports',
});
const jobProviderOptions: JobParamsProviderOptions = {
shareableUrl: isDirty ? shareableUrl : shareableUrlForSavedObject ?? shareableUrl,
objectType,
sharingData,
};
const isJobV2Params = ({
sharingData: _sharingData,
}: {
sharingData: Record<string, unknown>;
}) => _sharingData.locatorParams != null;
const isV2Job = isJobV2Params(jobProviderOptions);
const requiresSavedState = !isV2Job;
const panelPng = {
shareMenuItem: {
name: pngPanelTitle,
icon: 'document',
toolTipContent: licenseToolTipContent,
disabled: licenseDisabled || sharingData.reportingDisabled,
['data-test-subj']: 'PNGReports',
sortOrder: 10,
},
panel: {
id: 'reportingPngPanel',
title: pngPanelTitle,
content: (
<ScreenCapturePanelContent
apiClient={apiClient}
startServices$={startServices$}
reportType={'pngV2'}
objectId={objectId}
requiresSavedState={requiresSavedState}
getJobParams={getJobParams(jobProviderOptions, 'pngV2')}
isDirty={isDirty}
onClose={onClose}
/>
),
},
};
const pdfPanelTitle = i18n.translate('reporting.share.contextMenu.pdfReportsButtonLabel', {
defaultMessage: 'PDF Reports',
});
const panelPdf = {
shareMenuItem: {
name: pdfPanelTitle,
icon: 'document',
toolTipContent: licenseToolTipContent,
disabled: licenseDisabled || sharingData.reportingDisabled,
['data-test-subj']: 'PDFReports',
sortOrder: 10,
},
panel: {
id: 'reportingPdfPanel',
title: pdfPanelTitle,
content: (
<ScreenCapturePanelContent
apiClient={apiClient}
startServices$={startServices$}
reportType={'printablePdfV2'}
objectId={objectId}
requiresSavedState={requiresSavedState}
layoutOption={objectType === 'dashboard' ? 'print' : undefined}
getJobParams={getJobParams(jobProviderOptions, 'printablePdfV2')}
isDirty={isDirty}
onClose={onClose}
/>
),
},
};
shareActions.push(panelPng);
shareActions.push(panelPdf);
return shareActions;
};
return {
id: 'screenCaptureReports',
getShareMenuItems,
};
};
export const reportingExportModalProvider = ({ export const reportingExportModalProvider = ({
apiClient, apiClient,
license, license,
@ -249,7 +100,7 @@ export const reportingExportModalProvider = ({
} }
const { sharingData } = shareOpts as unknown as { sharingData: ReportingSharingData }; const { sharingData } = shareOpts as unknown as { sharingData: ReportingSharingData };
const shareActions: ShareMenuItem[] = []; const shareActions: ShareMenuItemV2[] = [];
const jobProviderOptions: JobParamsProviderOptions = { const jobProviderOptions: JobParamsProviderOptions = {
shareableUrl: isDirty ? shareableUrl : shareableUrlForSavedObject ?? shareableUrl, shareableUrl: isDirty ? shareableUrl : shareableUrlForSavedObject ?? shareableUrl,
@ -259,32 +110,9 @@ export const reportingExportModalProvider = ({
const requiresSavedState = sharingData.locatorParams === null; const requiresSavedState = sharingData.locatorParams === null;
const relativePathPDF = apiClient.getReportingPublicJobPath( const generateReportPDF = ({ intl, optimizedForPrinting = false }: ScreenshotExportOpts) => {
'printablePdfV2',
apiClient.getDecoratedJobParams(getJobParams(jobProviderOptions, 'printablePdfV2')())
);
const relativePathPNG = apiClient.getReportingPublicJobPath(
'pngV2',
apiClient.getDecoratedJobParams(getJobParams(jobProviderOptions, 'pngV2')())
);
const generateReportPDF = ({
intl,
optimizedForPrinting = false,
}: {
intl: InjectedIntl;
optimizedForPrinting?: boolean;
}) => {
const el = document.querySelector('[data-shared-items-container]');
const { height, width } = el ? el.getBoundingClientRect() : { height: 768, width: 1024 };
const dimensions = { height, width };
const decoratedJobParams = apiClient.getDecoratedJobParams({ const decoratedJobParams = apiClient.getDecoratedJobParams({
...getJobParams(jobProviderOptions, 'printablePdfV2')(), ...getJobParams({ ...jobProviderOptions, optimizedForPrinting }, 'printablePdfV2')(),
layout: { id: optimizedForPrinting ? 'print' : 'preserve_layout', dimensions },
objectType,
title: sharingData.title,
}); });
return apiClient return apiClient
@ -330,19 +158,27 @@ export const reportingExportModalProvider = ({
}); });
}; };
const generateReportPNG = ({ intl }: { intl: InjectedIntl }) => { const generateExportUrlPDF = ({ optimizedForPrinting }: ScreenshotExportOpts) => {
const { layout: outerLayout } = getJobParams(jobProviderOptions, 'pngV2')(); const jobParams = apiClient.getDecoratedJobParams(
let dimensions = outerLayout?.dimensions; getJobParams({ ...jobProviderOptions, optimizedForPrinting }, 'printablePdfV2')()
if (!dimensions) { );
const el = document.querySelector('[data-shared-items-container]'); const relativePathPDF = apiClient.getReportingPublicJobPath('printablePdfV2', jobParams);
const { height, width } = el ? el.getBoundingClientRect() : { height: 768, width: 1024 };
dimensions = { height, width }; return new URL(relativePathPDF, window.location.href).toString();
} };
const generateExportUrlPNG = () => {
const jobParams = apiClient.getDecoratedJobParams(
getJobParams(jobProviderOptions, 'pngV2')()
);
const relativePathPNG = apiClient.getReportingPublicJobPath('pngV2', jobParams);
return new URL(relativePathPNG, window.location.href).toString();
};
const generateReportPNG = ({ intl }: ScreenshotExportOpts) => {
const decoratedJobParams = apiClient.getDecoratedJobParams({ const decoratedJobParams = apiClient.getDecoratedJobParams({
...getJobParams(jobProviderOptions, 'pngV2')(), ...getJobParams(jobProviderOptions, 'pngV2')(),
layout: { id: 'preserve_layout', dimensions },
objectType,
title: sharingData.title,
}); });
return apiClient return apiClient
.createReportingJob('pngV2', decoratedJobParams) .createReportingJob('pngV2', decoratedJobParams)
@ -398,6 +234,7 @@ export const reportingExportModalProvider = ({
}, },
label: 'PDF' as const, label: 'PDF' as const,
generateExport: generateReportPDF, generateExport: generateReportPDF,
generateExportUrl: generateExportUrlPDF,
reportType: 'printablePdfV2', reportType: 'printablePdfV2',
requiresSavedState, requiresSavedState,
helpText: ( helpText: (
@ -415,7 +252,6 @@ export const reportingExportModalProvider = ({
layoutOption: objectType === 'dashboard' ? ('print' as const) : undefined, layoutOption: objectType === 'dashboard' ? ('print' as const) : undefined,
renderLayoutOptionSwitch: objectType === 'dashboard', renderLayoutOptionSwitch: objectType === 'dashboard',
renderCopyURLButton: true, renderCopyURLButton: true,
absoluteUrl: new URL(relativePathPDF, window.location.href).toString(),
}); });
shareActions.push({ shareActions.push({
@ -429,6 +265,7 @@ export const reportingExportModalProvider = ({
}, },
label: 'PNG' as const, label: 'PNG' as const,
generateExport: generateReportPNG, generateExport: generateReportPNG,
generateExportUrl: generateExportUrlPNG,
reportType: 'pngV2', reportType: 'pngV2',
requiresSavedState, requiresSavedState,
helpText: ( helpText: (
@ -442,7 +279,6 @@ export const reportingExportModalProvider = ({
), ),
layoutOption: objectType === 'dashboard' ? ('print' as const) : undefined, layoutOption: objectType === 'dashboard' ? ('print' as const) : undefined,
renderCopyURLButton: true, renderCopyURLButton: true,
absoluteUrl: new URL(relativePathPNG, window.location.href).toString(),
}); });
return shareActions; return shareActions;

View file

@ -13,7 +13,7 @@ import { createContext, useContext } from 'react';
import { AnonymousAccessServiceContract } from '../../../common'; import { AnonymousAccessServiceContract } from '../../../common';
import type { import type {
ShareMenuItem, ShareMenuItemV2,
UrlParamExtension, UrlParamExtension,
BrowserUrlService, BrowserUrlService,
ShareContext, ShareContext,
@ -24,7 +24,7 @@ export type { ShareMenuItemV2 } from '../../types';
export interface IShareContext extends ShareContext { export interface IShareContext extends ShareContext {
allowEmbed: boolean; allowEmbed: boolean;
allowShortUrl: boolean; allowShortUrl: boolean;
shareMenuItems: ShareMenuItem[]; shareMenuItems: ShareMenuItemV2[];
embedUrlParamExtensions?: UrlParamExtension[]; embedUrlParamExtensions?: UrlParamExtension[];
anonymousAccess?: AnonymousAccessServiceContract; anonymousAccess?: AnonymousAccessServiceContract;
urlService: BrowserUrlService; urlService: BrowserUrlService;

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1". * License v3.0 only", or the "Server Side Public License, v 1".
*/ */
import { ShareMenuItem } from '../types'; import { ShareMenuItemLegacy } from '../types';
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
@ -40,7 +40,7 @@ test('should disable the share URL when set', () => {
}); });
describe('shareContextMenuExtensions', () => { describe('shareContextMenuExtensions', () => {
const shareContextMenuItems: ShareMenuItem[] = [ const shareContextMenuItems: ShareMenuItemLegacy[] = [
{ {
panel: { panel: {
id: '1', id: '1',

View file

@ -17,7 +17,7 @@ import type { Capabilities } from '@kbn/core/public';
import type { LocatorPublic } from '../../common'; import type { LocatorPublic } from '../../common';
import { UrlPanelContent } from './url_panel_content'; import { UrlPanelContent } from './url_panel_content';
import { ShareMenuItem, ShareContextMenuPanelItem, UrlParamExtension } from '../types'; import { ShareMenuItemLegacy, ShareContextMenuPanelItem, UrlParamExtension } from '../types';
import { AnonymousAccessServiceContract } from '../../common/anonymous_access'; import { AnonymousAccessServiceContract } from '../../common/anonymous_access';
import type { BrowserUrlService } from '../types'; import type { BrowserUrlService } from '../types';
@ -32,7 +32,7 @@ export interface ShareContextMenuProps {
locator: LocatorPublic<any>; locator: LocatorPublic<any>;
params: any; params: any;
}; };
shareMenuItems: ShareMenuItem[]; shareMenuItems: ShareMenuItemLegacy[];
sharingData: any; sharingData: any;
onClose: () => void; onClose: () => void;
embedUrlParamExtensions?: UrlParamExtension[]; embedUrlParamExtensions?: UrlParamExtension[];

View file

@ -62,12 +62,19 @@ const mockShareContext = {
toasts: toastsServiceMock.createStartContract(), toasts: toastsServiceMock.createStartContract(),
i18n: i18nServiceMock.createStartContract(), i18n: i18nServiceMock.createStartContract(),
}; };
const mockGenerateExport = jest.fn();
const mockGenerateExportUrl = jest.fn().mockImplementation(() => 'generated-export-url');
const CSV = 'CSV' as const; const CSV = 'CSV' as const;
const PNG = 'PNG' as const; const PNG = 'PNG' as const;
describe('Share modal tabs', () => { describe('Share modal tabs', () => {
it('should render export tab when there are share menu items that are not disabled', async () => { it('should render export tab when there are share menu items that are not disabled', async () => {
const testItem = [ const testItem = [
{ shareMenuItem: { name: 'test', disabled: false }, label: CSV, generateExport: jest.fn() }, {
shareMenuItem: { name: 'test', disabled: false },
label: CSV,
generateExport: mockGenerateExport,
generateExportUrl: mockGenerateExportUrl,
},
]; ];
const wrapper = mountWithIntl( const wrapper = mountWithIntl(
<ShareTabsContext.Provider value={{ ...mockShareContext, shareMenuItems: testItem }}> <ShareTabsContext.Provider value={{ ...mockShareContext, shareMenuItems: testItem }}>
@ -78,7 +85,12 @@ describe('Share modal tabs', () => {
}); });
it('should not render export tab when the license is disabled', async () => { it('should not render export tab when the license is disabled', async () => {
const testItems = [ const testItems = [
{ shareMenuItem: { name: 'test', disabled: true }, label: CSV, generateExport: jest.fn() }, {
shareMenuItem: { name: 'test', disabled: true },
label: CSV,
generateExport: mockGenerateExport,
generateExportUrl: mockGenerateExportUrl,
},
]; ];
const wrapper = mountWithIntl( const wrapper = mountWithIntl(
<ShareTabsContext.Provider value={{ ...mockShareContext, shareMenuItems: testItems }}> <ShareTabsContext.Provider value={{ ...mockShareContext, shareMenuItems: testItems }}>
@ -90,8 +102,18 @@ describe('Share modal tabs', () => {
it('should render export tab is at least one is not disabled', async () => { it('should render export tab is at least one is not disabled', async () => {
const testItem = [ const testItem = [
{ shareMenuItem: { name: 'test', disabled: false }, label: CSV, generateExport: jest.fn() }, {
{ shareMenuItem: { name: 'test', disabled: true }, label: PNG, generateExport: jest.fn() }, shareMenuItem: { name: 'test', disabled: false },
label: CSV,
generateExport: mockGenerateExport,
generateExportUrl: mockGenerateExportUrl,
},
{
shareMenuItem: { name: 'test', disabled: true },
label: PNG,
generateExport: mockGenerateExport,
generateExportUrl: mockGenerateExportUrl,
},
]; ];
const wrapper = mountWithIntl( const wrapper = mountWithIntl(
<ShareTabsContext.Provider value={{ ...mockShareContext, shareMenuItems: testItem }}> <ShareTabsContext.Provider value={{ ...mockShareContext, shareMenuItems: testItem }}>

View file

@ -64,7 +64,7 @@ const ExportContentUi = ({
helpText, helpText,
renderCopyURLButton, renderCopyURLButton,
generateExport, generateExport,
absoluteUrl, generateExportUrl,
renderLayoutOptionSwitch, renderLayoutOptionSwitch,
} = useMemo(() => { } = useMemo(() => {
return aggregateReportTypes?.find(({ reportType }) => reportType === selectedRadio)!; return aggregateReportTypes?.find(({ reportType }) => reportType === selectedRadio)!;
@ -124,7 +124,8 @@ const ExportContentUi = ({
}, [usePrintLayout, renderLayoutOptionSwitch, handlePrintLayoutChange]); }, [usePrintLayout, renderLayoutOptionSwitch, handlePrintLayoutChange]);
const showCopyURLButton = useCallback(() => { const showCopyURLButton = useCallback(() => {
if (renderCopyURLButton && publicAPIEnabled) if (renderCopyURLButton && publicAPIEnabled) {
const absoluteUrl = generateExportUrl?.({ intl, optimizedForPrinting: usePrintLayout });
return ( return (
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false} css={{ flexGrow: 0 }}> <EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false} css={{ flexGrow: 0 }}>
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
@ -160,7 +161,8 @@ const ExportContentUi = ({
</EuiFlexItem> </EuiFlexItem>
</EuiFlexGroup> </EuiFlexGroup>
); );
}, [absoluteUrl, renderCopyURLButton, publicAPIEnabled]); }
}, [renderCopyURLButton, publicAPIEnabled, usePrintLayout, generateExportUrl, intl]);
const renderGenerateReportButton = useCallback(() => { const renderGenerateReportButton = useCallback(() => {
return ( return (

View file

@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n';
import React from 'react'; import React from 'react';
import { type IModalTabDeclaration } from '@kbn/shared-ux-tabbed-modal'; import { type IModalTabDeclaration } from '@kbn/shared-ux-tabbed-modal';
import { ExportContent } from './export_content'; import { ExportContent } from './export_content';
import { useShareTabsContext, type ShareMenuItemV2 } from '../../context'; import { useShareTabsContext } from '../../context';
type IExportTab = IModalTabDeclaration; type IExportTab = IModalTabDeclaration;
@ -23,8 +23,7 @@ const ExportTabContent = () => {
objectType={objectType} objectType={objectType}
isDirty={isDirty} isDirty={isDirty}
onClose={onClose} onClose={onClose}
// we are guaranteed that shareMenuItems will be a ShareMenuItem V2 variant aggregateReportTypes={shareMenuItems}
aggregateReportTypes={shareMenuItems as unknown as ShareMenuItemV2[]}
publicAPIEnabled={publicAPIEnabled ?? true} publicAPIEnabled={publicAPIEnabled ?? true}
/> />
); );

View file

@ -23,7 +23,8 @@ export type {
export type { export type {
ShareContext, ShareContext,
ShareMenuProvider, ShareMenuProvider,
ShareMenuItem, ShareMenuItemLegacy,
ShareMenuItemV2,
ShowShareMenuOptions, ShowShareMenuOptions,
ShareContextMenuPanelItem, ShareContextMenuPanelItem,
BrowserUrlService, BrowserUrlService,

View file

@ -11,10 +11,10 @@ import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { toMountPoint } from '@kbn/react-kibana-mount'; import { toMountPoint } from '@kbn/react-kibana-mount';
import { CoreStart, ThemeServiceStart, ToastsSetup } from '@kbn/core/public'; import { CoreStart, ThemeServiceStart, ToastsSetup } from '@kbn/core/public';
import { ShareMenuItem, ShowShareMenuOptions } from '../types'; import { ShowShareMenuOptions } from '../types';
import { ShareMenuRegistryStart } from './share_menu_registry'; import { ShareMenuRegistryStart } from './share_menu_registry';
import { AnonymousAccessServiceContract } from '../../common/anonymous_access'; import { AnonymousAccessServiceContract } from '../../common/anonymous_access';
import type { BrowserUrlService } from '../types'; import type { BrowserUrlService, ShareMenuItemV2 } from '../types';
import { ShareMenu } from '../components/share_tabs'; import { ShareMenu } from '../components/share_tabs';
export class ShareMenuManager { export class ShareMenuManager {
@ -89,7 +89,7 @@ export class ShareMenuManager {
publicAPIEnabled, publicAPIEnabled,
}: ShowShareMenuOptions & { }: ShowShareMenuOptions & {
anchorElement: HTMLElement; anchorElement: HTMLElement;
menuItems: ShareMenuItem[]; menuItems: ShareMenuItemV2[];
urlService: BrowserUrlService; urlService: BrowserUrlService;
anonymousAccess: AnonymousAccessServiceContract | undefined; anonymousAccess: AnonymousAccessServiceContract | undefined;
theme: ThemeServiceStart; theme: ThemeServiceStart;

View file

@ -13,7 +13,7 @@ import {
ShareMenuRegistrySetup, ShareMenuRegistrySetup,
ShareMenuRegistryStart, ShareMenuRegistryStart,
} from './share_menu_registry'; } from './share_menu_registry';
import { ShareMenuItem, ShareContext } from '../types'; import { ShareMenuItemV2, ShareContext } from '../types';
const createSetupMock = (): jest.Mocked<ShareMenuRegistrySetup> => { const createSetupMock = (): jest.Mocked<ShareMenuRegistrySetup> => {
const setup = { const setup = {
@ -24,7 +24,7 @@ const createSetupMock = (): jest.Mocked<ShareMenuRegistrySetup> => {
const createStartMock = (): jest.Mocked<ShareMenuRegistryStart> => { const createStartMock = (): jest.Mocked<ShareMenuRegistryStart> => {
const start = { const start = {
getShareMenuItems: jest.fn((props: ShareContext) => [] as ShareMenuItem[]), getShareMenuItems: jest.fn((_props: ShareContext) => [] as ShareMenuItemV2[]),
}; };
return start; return start;
}; };

View file

@ -8,7 +8,7 @@
*/ */
import { ShareMenuRegistry } from './share_menu_registry'; import { ShareMenuRegistry } from './share_menu_registry';
import { ShareMenuItem, ShareContext } from '../types'; import { ShareMenuItemV2, ShareContext } from '../types';
describe('ShareActionsRegistry', () => { describe('ShareActionsRegistry', () => {
describe('setup', () => { describe('setup', () => {
@ -34,9 +34,9 @@ describe('ShareActionsRegistry', () => {
test('returns a flat list of actions returned by all providers', () => { test('returns a flat list of actions returned by all providers', () => {
const service = new ShareMenuRegistry(); const service = new ShareMenuRegistry();
const registerFunction = service.setup().register; const registerFunction = service.setup().register;
const shareAction1 = {} as ShareMenuItem; const shareAction1 = {} as ShareMenuItemV2;
const shareAction2 = {} as ShareMenuItem; const shareAction2 = {} as ShareMenuItemV2;
const shareAction3 = {} as ShareMenuItem; const shareAction3 = {} as ShareMenuItemV2;
const provider1Callback = jest.fn(() => [shareAction1]); const provider1Callback = jest.fn(() => [shareAction1]);
const provider2Callback = jest.fn(() => [shareAction2, shareAction3]); const provider2Callback = jest.fn(() => [shareAction2, shareAction3]);
registerFunction({ registerFunction({

View file

@ -7,7 +7,12 @@
* License v3.0 only", or the "Server Side Public License, v 1". * License v3.0 only", or the "Server Side Public License, v 1".
*/ */
import { ShareContext, ShareMenuProvider } from '../types'; import {
ShareContext,
ShareMenuProvider,
ShareMenuProviderV2,
ShareMenuProviderLegacy,
} from '../types';
export class ShareMenuRegistry { export class ShareMenuRegistry {
private readonly shareMenuProviders = new Map<string, ShareMenuProvider>(); private readonly shareMenuProviders = new Map<string, ShareMenuProvider>();
@ -36,7 +41,10 @@ export class ShareMenuRegistry {
return { return {
getShareMenuItems: (context: ShareContext) => getShareMenuItems: (context: ShareContext) =>
Array.from(this.shareMenuProviders.values()).flatMap((shareActionProvider) => Array.from(this.shareMenuProviders.values()).flatMap((shareActionProvider) =>
shareActionProvider.getShareMenuItems(context) (
(shareActionProvider as ShareMenuProviderV2).getShareMenuItems ??
(shareActionProvider as ShareMenuProviderLegacy).getShareMenuItemsLegacy
).call(shareActionProvider, context)
), ),
}; };
} }

View file

@ -100,10 +100,16 @@ export type SupportedExportTypes =
interface ShareMenuItemBase { interface ShareMenuItemBase {
shareMenuItem?: ShareContextMenuPanelItem; shareMenuItem?: ShareContextMenuPanelItem;
} }
interface ShareMenuItemLegacy extends ShareMenuItemBase {
export interface ShareMenuItemLegacy extends ShareMenuItemBase {
panel?: EuiContextMenuPanelDescriptor; panel?: EuiContextMenuPanelDescriptor;
} }
export interface ScreenshotExportOpts {
optimizedForPrinting?: boolean;
intl: InjectedIntl;
}
export interface ShareMenuItemV2 extends ShareMenuItemBase { export interface ShareMenuItemV2 extends ShareMenuItemBase {
// extended props to support share modal // extended props to support share modal
label: 'PDF' | 'CSV' | 'PNG'; label: 'PDF' | 'CSV' | 'PNG';
@ -112,21 +118,31 @@ export interface ShareMenuItemV2 extends ShareMenuItemBase {
helpText?: ReactElement; helpText?: ReactElement;
copyURLButton?: { id: string; dataTestSubj: string; label: string }; copyURLButton?: { id: string; dataTestSubj: string; label: string };
generateExportButton?: ReactElement; generateExportButton?: ReactElement;
generateExport: (args: { /**
intl: InjectedIntl; * Function to trigger an export
optimizedForPrinting?: boolean; */
}) => Promise<unknown>; generateExport: (args: ScreenshotExportOpts) => Promise<unknown>;
/**
* Function to generate a URL to be used for automating export
* Not applicable for exports that do not call a remote API (i.e Lens CSV export)
*/
generateExportUrl?: (args: ScreenshotExportOpts) => string | undefined;
theme?: ThemeServiceSetup; theme?: ThemeServiceSetup;
renderLayoutOptionSwitch?: boolean; renderLayoutOptionSwitch?: boolean;
layoutOption?: 'print'; layoutOption?: 'print';
absoluteUrl?: string;
generateCopyUrl?: URL; generateCopyUrl?: URL;
renderCopyURLButton?: boolean; renderCopyURLButton?: boolean;
} }
export type ShareMenuItem = ShareMenuItemLegacy | ShareMenuItemV2; export interface ShareMenuProviderV2 {
readonly id: string;
getShareMenuItems: (context: ShareContext) => Array<Omit<ShareMenuItemV2, 'intl'>>;
}
export interface ShareMenuProviderLegacy {
readonly id: string;
getShareMenuItemsLegacy: (context: ShareContext) => ShareMenuItemLegacy[];
}
type ShareMenuItemType = Omit<ShareMenuItem, 'intl'>;
/** /**
* @public * @public
* A source for additional menu items shown in the share context menu. Any provider * A source for additional menu items shown in the share context menu. Any provider
@ -134,10 +150,7 @@ type ShareMenuItemType = Omit<ShareMenuItem, 'intl'>;
* menu. Returned `ShareMenuItem`s will be shown in the context menu together with the * menu. Returned `ShareMenuItem`s will be shown in the context menu together with the
* default built-in share options. Each share provider needs a globally unique id. * default built-in share options. Each share provider needs a globally unique id.
* */ * */
export interface ShareMenuProvider { export type ShareMenuProvider = ShareMenuProviderV2 | ShareMenuProviderLegacy;
readonly id: string;
getShareMenuItems: (context: ShareContext) => ShareMenuItemType[];
}
interface UrlParamExtensionProps { interface UrlParamExtensionProps {
setParamValue: (values: {}) => void; setParamValue: (values: {}) => void;

View file

@ -144,8 +144,8 @@ export const downloadCsvShareProvider = ({
return [ return [
{ {
...menuItemMetadata, ...menuItemMetadata,
label: 'CSV', label: 'CSV' as const,
reportType: 'lens_csv', reportType: 'lens_csv' as const,
generateExport: downloadCSVHandler, generateExport: downloadCSVHandler,
...(atLeastGold() ...(atLeastGold()
? { ? {

View file

@ -6122,8 +6122,6 @@
"reporting.printablePdfV2.generateButtonLabel": "Exporter un fichier", "reporting.printablePdfV2.generateButtonLabel": "Exporter un fichier",
"reporting.printablePdfV2.helpText": "Sélectionnez le type de fichier que vous souhaitez exporter pour cette visualisation.", "reporting.printablePdfV2.helpText": "Sélectionnez le type de fichier que vous souhaitez exporter pour cette visualisation.",
"reporting.share.contextMenu.export.csvReportsButtonLabel": "Exporter", "reporting.share.contextMenu.export.csvReportsButtonLabel": "Exporter",
"reporting.share.contextMenu.pdfReportsButtonLabel": "Rapports PDF",
"reporting.share.contextMenu.pngReportsButtonLabel": "Rapports PNG",
"reporting.share.csv.reporting.helpTextCSV": "Exporter un fichier CSV à partir de ce {objectType}.", "reporting.share.csv.reporting.helpTextCSV": "Exporter un fichier CSV à partir de ce {objectType}.",
"reporting.share.generateButtonLabelCSV": "Générer un CSV", "reporting.share.generateButtonLabelCSV": "Générer un CSV",
"reporting.share.modalContent.notification.reportingErrorTitle": "Impossible de créer le rapport", "reporting.share.modalContent.notification.reportingErrorTitle": "Impossible de créer le rapport",

View file

@ -5876,8 +5876,6 @@
"reporting.printablePdfV2.generateButtonLabel": "ファイルのエクスポート", "reporting.printablePdfV2.generateButtonLabel": "ファイルのエクスポート",
"reporting.printablePdfV2.helpText": "このビジュアライゼーションでエクスポートするファイルタイプを選択します。", "reporting.printablePdfV2.helpText": "このビジュアライゼーションでエクスポートするファイルタイプを選択します。",
"reporting.share.contextMenu.export.csvReportsButtonLabel": "エクスポート", "reporting.share.contextMenu.export.csvReportsButtonLabel": "エクスポート",
"reporting.share.contextMenu.pdfReportsButtonLabel": "PDF レポート",
"reporting.share.contextMenu.pngReportsButtonLabel": "PNG レポート",
"reporting.share.csv.reporting.helpTextCSV": "この{objectType}のCSVをエクスポートします。", "reporting.share.csv.reporting.helpTextCSV": "この{objectType}のCSVをエクスポートします。",
"reporting.share.generateButtonLabelCSV": "CSVを生成", "reporting.share.generateButtonLabelCSV": "CSVを生成",
"reporting.share.modalContent.notification.reportingErrorTitle": "レポートを作成できません", "reporting.share.modalContent.notification.reportingErrorTitle": "レポートを作成できません",

View file

@ -5889,8 +5889,6 @@
"reporting.printablePdfV2.generateButtonLabel": "导出文件", "reporting.printablePdfV2.generateButtonLabel": "导出文件",
"reporting.printablePdfV2.helpText": "为此可视化选择您要导出的文件类型。", "reporting.printablePdfV2.helpText": "为此可视化选择您要导出的文件类型。",
"reporting.share.contextMenu.export.csvReportsButtonLabel": "导出", "reporting.share.contextMenu.export.csvReportsButtonLabel": "导出",
"reporting.share.contextMenu.pdfReportsButtonLabel": "PDF 报告",
"reporting.share.contextMenu.pngReportsButtonLabel": "PNG 报告",
"reporting.share.csv.reporting.helpTextCSV": "导出此 {objectType} 的 CSV。", "reporting.share.csv.reporting.helpTextCSV": "导出此 {objectType} 的 CSV。",
"reporting.share.generateButtonLabelCSV": "生成 CSV", "reporting.share.generateButtonLabelCSV": "生成 CSV",
"reporting.share.modalContent.notification.reportingErrorTitle": "无法创建报告", "reporting.share.modalContent.notification.reportingErrorTitle": "无法创建报告",

View file

@ -127,6 +127,34 @@ export default function ({
expect(res.get('content-type')).to.equal('application/pdf'); expect(res.get('content-type')).to.equal('application/pdf');
await share.closeShareModal(); await share.closeShareModal();
}); });
it('provides a button to copy POST URL', async () => {
// The "clipboard-read" permission of the Permissions API must be granted
if (!(await browser.checkBrowserPermission('clipboard-read'))) {
return;
}
await dashboard.navigateToApp();
await dashboard.loadSavedDashboard('Ecom Dashboard');
await reporting.openExportTab();
await reporting.checkUsePrintLayout();
await testSubjects.click('shareReportingCopyURL');
const postUrl = await browser.getClipboardValue();
expect(postUrl).to.contain('printablePdfV2');
const [, jobParams] = postUrl.split('jobParams=');
expect(decodeURIComponent(jobParams)).to.contain('browserTimezone:UTC,');
expect(decodeURIComponent(jobParams)).to.match(
/layout:\(dimensions:\(height:1\d{3},width:1\d{3}\),id:print\),/
);
expect(decodeURIComponent(jobParams)).to.match(
/objectType:dashboard,title:'Ecom Dashboard',/
);
expect(decodeURIComponent(jobParams)).to.match(
/locatorParams:.*id:DASHBOARD_APP_LOCATOR,params:\(dashboardId:'6c263e00-1c6d-11ea-a100-8589bb9d7c6b',/
);
});
}); });
describe('Print PNG button', () => { describe('Print PNG button', () => {
@ -153,9 +181,37 @@ export default function ({
expect(await reporting.isGenerateReportButtonDisabled()).to.be(null); expect(await reporting.isGenerateReportButtonDisabled()).to.be(null);
await (await testSubjects.find('kibanaChrome')).clickMouseButton(); // close popover await (await testSubjects.find('kibanaChrome')).clickMouseButton(); // close popover
}); });
it('provides a button to copy POST URL', async () => {
// The "clipboard-read" permission of the Permissions API must be granted
if (!(await browser.checkBrowserPermission('clipboard-read'))) {
return;
}
await dashboard.navigateToApp();
await dashboard.loadSavedDashboard('Ecom Dashboard');
await reporting.openExportTab();
await testSubjects.click('pngV2-radioOption');
await testSubjects.click('shareReportingCopyURL');
const postUrl = await browser.getClipboardValue();
expect(postUrl).to.contain('pngV2');
const [, jobParams] = postUrl.split('jobParams=');
expect(decodeURIComponent(jobParams)).to.contain('browserTimezone:UTC,');
expect(decodeURIComponent(jobParams)).to.match(
/layout:\(dimensions:\(height:1\d{3},width:1\d{3}\),id:preserve_layout\),/
);
expect(decodeURIComponent(jobParams)).to.match(
/objectType:dashboard,title:'Ecom Dashboard',/
);
expect(decodeURIComponent(jobParams)).to.match(
/locatorParams:.*id:DASHBOARD_APP_LOCATOR,params:\(dashboardId:'6c263e00-1c6d-11ea-a100-8589bb9d7c6b',/
);
});
}); });
describe.skip('Preserve Layout', () => { describe('Preserve Layout', () => {
before(async () => { before(async () => {
await loadEcommerce(); await loadEcommerce();
}); });
@ -180,6 +236,33 @@ export default function ({
expect(res.get('content-type')).to.equal('application/pdf'); expect(res.get('content-type')).to.equal('application/pdf');
await kibanaServer.uiSettings.replace({}); await kibanaServer.uiSettings.replace({});
}); });
it('provides a button to copy POST URL', async () => {
// The "clipboard-read" permission of the Permissions API must be granted
if (!(await browser.checkBrowserPermission('clipboard-read'))) {
return;
}
await dashboard.navigateToApp();
await dashboard.loadSavedDashboard('Ecom Dashboard');
await reporting.openExportTab();
await testSubjects.click('shareReportingCopyURL');
const postUrl = await browser.getClipboardValue();
expect(postUrl).to.contain('printablePdfV2');
const [, jobParams] = postUrl.split('jobParams=');
expect(decodeURIComponent(jobParams)).to.contain('browserTimezone:UTC,');
expect(decodeURIComponent(jobParams)).to.match(
/layout:\(dimensions:\(height:1\d{3},width:1\d{3}\),id:preserve_layout\),/
);
expect(decodeURIComponent(jobParams)).to.match(
/objectType:dashboard,title:'Ecom Dashboard',/
);
expect(decodeURIComponent(jobParams)).to.match(
/locatorParams:.*id:DASHBOARD_APP_LOCATOR,params:\(dashboardId:'6c263e00-1c6d-11ea-a100-8589bb9d7c6b',/
);
});
}); });
describe('Sample data from Kibana 7.6', () => { describe('Sample data from Kibana 7.6', () => {