[Share Modal Redesign] Reporting Refactor Modals (#180009)

## Summary

This PR refactors https://github.com/elastic/kibana/pull/179206 to have
each export type be registered in Reporting and then passed into the
share plugin.

This PR is focused on the redesign in terms of the export modals. Test
refactoring will be done in a separate PR.
Partially closes https://github.com/elastic/kibana-team/issues/753

- [x] Need to refactor this PR to include @eokoneyo's general modal
component
- [x] Lens needs to have Export with all three report type options - to
avoid circular dependencies move the Lens CSV stuff into the reporting
plugin vs having it in Lens
- [x] Canvas should not be affected by these changes (so the old
share/reporting code has to stay for canvas)
https://github.com/elastic/kibana/issues/151523 to keep in mind for the
redesign

Failed tests will be covered in this PR
https://github.com/elastic/kibana/pull/180406


### TO TEST 

Mark `share.new_version.enabled: true` in your kibana.dev.yml


### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))

---------

Co-authored-by: Eyo Okon Eyo <eyo.eyo@elastic.co>
Co-authored-by: Tim Sullivan <tsullivan@users.noreply.github.com>
Co-authored-by: Marco Liberati <dej611@users.noreply.github.com>
This commit is contained in:
Rachel Shen 2024-04-16 16:01:44 -06:00 committed by GitHub
parent e661eea406
commit 9579635c25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 1654 additions and 1282 deletions

2
.github/CODEOWNERS vendored
View file

@ -646,7 +646,7 @@ packages/kbn-repo-path @elastic/kibana-operations
packages/kbn-repo-source-classifier @elastic/kibana-operations
packages/kbn-repo-source-classifier-cli @elastic/kibana-operations
packages/kbn-reporting/common @elastic/appex-sharedux
x-pack/examples/reporting_example @elastic/appex-sharedux
packages/kbn-reporting/get_csv_panel_actions @elastic/appex-sharedux
packages/kbn-reporting/export_types/csv @elastic/appex-sharedux
packages/kbn-reporting/export_types/csv_common @elastic/appex-sharedux
packages/kbn-reporting/export_types/pdf @elastic/appex-sharedux

View file

@ -651,7 +651,7 @@
"@kbn/repo-info": "link:packages/kbn-repo-info",
"@kbn/repo-packages": "link:packages/kbn-repo-packages",
"@kbn/reporting-common": "link:packages/kbn-reporting/common",
"@kbn/reporting-example-plugin": "link:x-pack/examples/reporting_example",
"@kbn/reporting-csv-share-panel": "link:packages/kbn-reporting/get_csv_panel_actions",
"@kbn/reporting-export-types-csv": "link:packages/kbn-reporting/export_types/csv",
"@kbn/reporting-export-types-csv-common": "link:packages/kbn-reporting/export_types/csv_common",
"@kbn/reporting-export-types-pdf": "link:packages/kbn-reporting/export_types/pdf",

View file

@ -115,7 +115,7 @@ pageLoadAssetSize:
presentationUtil: 58834
profiling: 36694
remoteClusters: 51327
reporting: 57003
reporting: 58600
rollup: 97204
runtimeFields: 41752
savedObjects: 108518
@ -138,7 +138,7 @@ pageLoadAssetSize:
serverlessObservability: 68747
serverlessSearch: 72995
sessionView: 77750
share: 71239
share: 88160
slo: 37039
snapshotRestore: 79032
spaces: 57868

View file

@ -0,0 +1,9 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export { ReportingCsvPanelAction } from './panel_actions/get_csv_panel_action';

View file

@ -0,0 +1,13 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/packages/kbn-reporting/get_csv_panel_actions'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-browser",
"id": "@kbn/reporting-csv-share-panel",
"owner": "@elastic/appex-sharedux"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/reporting-csv-share-panel",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -16,8 +16,8 @@ import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import { LicenseCheckState } from '@kbn/licensing-plugin/public';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import { ReportingAPIClient } from '../..';
import type { ClientConfigType } from '../../types';
import { ReportingAPIClient } from '@kbn/reporting-public';
import type { ClientConfigType } from '@kbn/reporting-public/types';
import {
ActionContext,
type PanelActionDependencies,

View file

@ -28,9 +28,9 @@ import type { UiActionsActionDefinition as ActionDefinition } from '@kbn/ui-acti
import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { CSV_REPORTING_ACTION, JobAppParamsCSV } from '@kbn/reporting-export-types-csv-common';
import type { ClientConfigType } from '../../types';
import { checkLicense } from '../../license_check';
import type { ReportingAPIClient } from '../../reporting_api_client';
import type { ClientConfigType } from '@kbn/reporting-public/types';
import { checkLicense } from '@kbn/reporting-public/license_check';
import type { ReportingAPIClient } from '@kbn/reporting-public/reporting_api_client';
import { getI18nStrings } from './strings';
function isSavedSearchEmbeddable(

View file

@ -9,7 +9,7 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { ReportingAPIClient } from '../../reporting_api_client';
import type { ReportingAPIClient } from '@kbn/reporting-public/reporting_api_client';
interface I18nStrings {
displayName: string;

View file

@ -0,0 +1,31 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts", "**/*.tsx"
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/core",
"@kbn/data-plugin",
"@kbn/i18n",
"@kbn/reporting-export-types-csv-common",
"@kbn/licensing-plugin",
"@kbn/i18n-react",
"@kbn/discover-utils",
"@kbn/saved-search-plugin",
"@kbn/discover-plugin",
"@kbn/embeddable-plugin",
"@kbn/ui-actions-plugin",
"@kbn/react-kibana-mount",
"@kbn/reporting-public",
]
}

View file

@ -7,7 +7,9 @@
*/
export { getSharedComponents } from './shared';
export { reportingExportModalProvider } from './share_context_menu/register_pdf_png_modal_reporting';
export { reportingScreenshotShareProvider } from './share_context_menu/register_pdf_png_reporting';
export { reportingCsvShareProvider } from './share_context_menu/register_csv_reporting';
export { ReportingCsvPanelAction } from './panel_actions/get_csv_panel_action';
export { reportingCsvShareProvider as reportingCsvShareModalProvider } from './share_context_menu/register_csv_modal_reporting';
export type { ReportingPublicComponents } from './shared/get_shared_components';
export type { JobParamsProviderOptions } from './share_context_menu';

View file

@ -8,6 +8,7 @@
import type {
ApplicationStart,
I18nStart,
IUiSettingsClient,
ThemeServiceSetup,
ToastsSetup,
@ -16,6 +17,16 @@ import { ILicense } from '@kbn/licensing-plugin/public';
import type { LayoutParams } from '@kbn/screenshotting-plugin/common';
import type { ReportingAPIClient } from '../../reporting_api_client';
export interface ExportModalShareOpts {
apiClient: ReportingAPIClient;
uiSettings: IUiSettingsClient;
usesUiCapabilities: boolean;
license: ILicense;
application: ApplicationStart;
theme: ThemeServiceSetup;
i18n: I18nStart;
}
export interface ExportPanelShareOpts {
apiClient: ReportingAPIClient;
toasts: ToastsSetup;

View file

@ -0,0 +1,188 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { CSV_JOB_TYPE, CSV_JOB_TYPE_V2 } from '@kbn/reporting-export-types-csv-common';
import type { SearchSourceFields } from '@kbn/data-plugin/common';
import { ShareContext, ShareMenuItem } from '@kbn/share-plugin/public';
import { FormattedMessage, InjectedIntl } from '@kbn/i18n-react';
import type { ExportModalShareOpts } from '.';
import { checkLicense } from '../..';
export const reportingCsvShareProvider = ({
apiClient,
application,
license,
usesUiCapabilities,
i18n: i18nStart,
theme,
}: ExportModalShareOpts) => {
const getShareMenuItems = ({ objectType, sharingData, toasts }: ShareContext) => {
if ('search' !== objectType) {
return [];
}
// only csv v2 supports esql (isTextBased) reports
// TODO: whole csv reporting should move to v2 https://github.com/elastic/kibana/issues/151190
const reportType = sharingData.isTextBased ? CSV_JOB_TYPE_V2 : CSV_JOB_TYPE;
const getSearchSource = sharingData.getSearchSource as ({
addGlobalTimeFilter,
absoluteTime,
}: {
addGlobalTimeFilter?: boolean;
absoluteTime?: boolean;
}) => SearchSourceFields;
const jobParams = {
title: sharingData.title as string,
objectType,
};
const getJobParams = (forShareUrl?: boolean) => {
if (reportType === CSV_JOB_TYPE_V2) {
// csv v2 uses locator params
return {
...jobParams,
locatorParams: sharingData.locatorParams as [Record<string, unknown>],
};
}
// csv v1 uses search source and columns
return {
...jobParams,
columns: sharingData.columns as string[] | undefined,
searchSource: getSearchSource({
addGlobalTimeFilter: true,
absoluteTime: !forShareUrl,
}),
};
};
const shareActions: ShareMenuItem[] = [];
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
}
const generateReportingJobCSV = ({ intl }: { intl: InjectedIntl }) => {
const decoratedJobParams = apiClient.getDecoratedJobParams(getJobParams());
return apiClient
.createReportingJob(reportType, decoratedJobParams)
.then(() => {
toasts.addSuccess({
title: intl.formatMessage(
{
id: 'reporting.share.modalContent.successfullyQueuedReportNotificationTitle',
defaultMessage: 'Queued report for {objectType}',
},
{ objectType }
),
text: toMountPoint(
<FormattedMessage
id="reporting.share.modalContent.successfullyQueuedReportNotificationDescription"
defaultMessage="Track its progress in {path}."
values={{
path: (
<a href={apiClient.getManagementLink()}>
<FormattedMessage
id="reporting.share.publicNotifier.reportLink.reportingSectionUrlLinkLabel"
defaultMessage="Stack Management &gt; Reporting"
/>
</a>
),
}}
/>,
{ theme, i18n: i18nStart }
),
'data-test-subj': 'queueReportSuccess',
});
})
.catch((error) => {
toasts.addError(error, {
title: intl.formatMessage({
id: 'reporting.share.modalContent.notification.reportingErrorTitle',
defaultMessage: 'Unable to create report',
}),
toastMessage: error.body?.message,
});
});
};
if (licenseHasCsvReporting && capabilityHasCsvReporting) {
const panelTitle = i18n.translate(
'reporting.share.contextMenu.export.csvReportsButtonLabel',
{
defaultMessage: 'Export',
}
);
const reportingUrl = new URL(window.location.origin);
const relativePath = apiClient.getReportingPublicJobPath(
reportType,
apiClient.getDecoratedJobParams(getJobParams())
);
const absoluteUrl = new URL(relativePath, window.location.href).toString();
shareActions.push({
shareMenuItem: {
name: panelTitle,
toolTipContent: licenseToolTipContent,
disabled: licenseDisabled,
['data-test-subj']: 'Export',
},
helpText: (
<FormattedMessage
id="reporting.share.csv.reporting.helpTextCSV"
defaultMessage="Export a CSV of this {objectType}"
values={{ objectType }}
/>
),
reportType,
label: 'CSV',
copyURLButton: {
id: 'reporting.share.modalContent.csv.copyUrlButtonLabel',
dataTestSubj: 'shareReportingCopyURL',
label: 'Post URL',
},
generateReportButton: (
<FormattedMessage
id="reporting.share.generateButtonLabelCSV"
data-test-subj="generateReportButton"
defaultMessage="Generate CSV"
/>
),
generateReport: generateReportingJobCSV,
generateCopyUrl: reportingUrl,
absoluteUrl,
renderCopyURLButton: true,
});
}
return shareActions;
};
return {
id: 'csvReportsModal',
getShareMenuItems,
};
};

View file

@ -12,7 +12,7 @@ import React from 'react';
import { CSV_JOB_TYPE, CSV_JOB_TYPE_V2 } from '@kbn/reporting-export-types-csv-common';
import type { SearchSourceFields } from '@kbn/data-plugin/common';
import { ShareContext, ShareMenuProvider } from '@kbn/share-plugin/public';
import { ShareContext, ShareMenuItem, ShareMenuProvider } from '@kbn/share-plugin/public';
import type { ExportPanelShareOpts } from '.';
import { checkLicense } from '../..';
import { ReportingPanelContent } from './reporting_panel_content_lazy';
@ -68,7 +68,7 @@ export const reportingCsvShareProvider = ({
};
};
const shareActions = [];
const shareActions: ShareMenuItem[] = [];
const licenseCheck = checkLicense(license.check('reporting', 'basic'));
const licenseToolTipContent = licenseCheck.message;

View file

@ -0,0 +1,461 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ShareContext, ShareMenuItem, ShareMenuProvider } from '@kbn/share-plugin/public';
import { FormattedMessage, InjectedIntl } from '@kbn/i18n-react';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { checkLicense } from '../../license_check';
import {
ExportModalShareOpts,
ExportPanelShareOpts,
JobParamsProviderOptions,
ReportingSharingData,
} from '.';
import { ScreenCapturePanelContent } from './screen_capture_panel_content_lazy';
const getJobParams = (opts: JobParamsProviderOptions, type: 'pngV2' | 'printablePdfV2') => () => {
const {
objectType,
sharingData: { title, layout, locatorParams },
} = opts;
const baseParams = {
objectType,
layout,
title,
};
if (type === 'printablePdfV2') {
// multi locator for PDF V2
return { ...baseParams, locatorParams: [locatorParams] };
}
// single locator for PNG V2
return { ...baseParams, locatorParams };
};
/**
* This is used by Canvas
*/
export const reportingScreenshotShareProvider = ({
apiClient,
toasts,
uiSettings,
license,
application,
usesUiCapabilities,
theme,
}: 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}
toasts={toasts}
uiSettings={uiSettings}
reportType={'pngV2'}
objectId={objectId}
requiresSavedState={requiresSavedState}
getJobParams={getJobParams(jobProviderOptions, 'pngV2')}
isDirty={isDirty}
onClose={onClose}
theme={theme}
/>
),
},
};
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}
toasts={toasts}
uiSettings={uiSettings}
reportType={'printablePdfV2'}
objectId={objectId}
requiresSavedState={requiresSavedState}
layoutOption={objectType === 'dashboard' ? 'print' : undefined}
getJobParams={getJobParams(jobProviderOptions, 'printablePdfV2')}
isDirty={isDirty}
onClose={onClose}
theme={theme}
/>
),
},
};
shareActions.push(panelPng);
shareActions.push(panelPdf);
return shareActions;
};
return {
id: 'screenCaptureReports',
getShareMenuItems,
};
};
export const reportingExportModalProvider = ({
apiClient,
license,
application,
usesUiCapabilities,
theme,
i18n: i18nStart,
}: ExportModalShareOpts): ShareMenuProvider => {
const getShareMenuItems = ({
objectType,
objectId,
isDirty,
onClose,
shareableUrl,
shareableUrlForSavedObject,
toasts,
...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 [];
}
// for lens png pdf and csv are combined into one modal
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 jobProviderOptions: JobParamsProviderOptions = {
shareableUrl: isDirty ? shareableUrl : shareableUrlForSavedObject ?? shareableUrl,
objectType,
sharingData,
};
const requiresSavedState = sharingData.locatorParams === null;
const relativePathPDF = apiClient.getReportingPublicJobPath(
'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({
...getJobParams(jobProviderOptions, 'printablePdfV2')(),
layout: { id: optimizedForPrinting ? 'print' : 'preserve_layout', dimensions },
objectType,
title: sharingData.title,
});
return apiClient
.createReportingJob('printablePdfV2', decoratedJobParams)
.then(() => {
toasts.addSuccess({
title: intl.formatMessage(
{
id: 'reporting.share.modalContent.successfullyQueuedReportNotificationTitle',
defaultMessage: 'Queued report for {objectType}',
},
{ objectType }
),
text: toMountPoint(
<FormattedMessage
id="reporting.share.modalContent.successfullyQueuedReportNotificationDescription"
defaultMessage="Track its progress in {path}."
values={{
path: (
<a href={apiClient.getManagementLink()}>
<FormattedMessage
id="reporting.share.publicNotifier.reportLink.reportingSectionUrlLinkLabel"
defaultMessage="Stack Management &gt; Reporting"
/>
</a>
),
}}
/>,
{ theme, i18n: i18nStart }
),
'data-test-subj': 'queueReportSuccess',
});
})
.catch((error: any) => {
toasts.addError(error, {
title: intl.formatMessage({
id: 'reporting.share.modalContent.notification.reportingErrorTitle',
defaultMessage: 'Unable to create report',
}),
toastMessage: error.body?.message,
});
});
};
const generateReportPNG = ({ intl }: { intl: InjectedIntl }) => {
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({
...getJobParams(jobProviderOptions, 'pngV2')(),
layout: { id: 'preserve_layout', dimensions },
objectType,
title: sharingData.title,
});
return apiClient
.createReportingJob('pngV2', decoratedJobParams)
.then(() => {
toasts.addSuccess({
title: intl.formatMessage(
{
id: 'reporting.share.modalContent.successfullyQueuedReportNotificationTitle',
defaultMessage: 'Queued report for {objectType}',
},
{ objectType }
),
text: toMountPoint(
<FormattedMessage
id="reporting.share.modalContent.successfullyQueuedReportNotificationDescription"
defaultMessage="Track its progress in {path}."
values={{
path: (
<a href={apiClient.getManagementLink()}>
<FormattedMessage
id="reporting.share.publicNotifier.reportLink.reportingSectionUrlLinkLabel"
defaultMessage="Stack Management &gt; Reporting"
/>
</a>
),
}}
/>,
{ theme, i18n: i18nStart }
),
'data-test-subj': 'queueReportSuccess',
});
})
.catch((error: any) => {
toasts.addError(error, {
title: intl.formatMessage({
id: 'reporting.share.modalContent.notification.reportingErrorTitle',
defaultMessage: 'Unable to create report',
}),
toastMessage: error.body?.message,
});
});
};
shareActions.push({
shareMenuItem: {
name: i18n.translate('reporting.shareContextMenu.ExportsButtonLabel', {
defaultMessage: 'PDF',
}),
toolTipContent: licenseToolTipContent,
disabled: licenseDisabled || sharingData.reportingDisabled,
['data-test-subj']: 'imageExports',
},
label: 'PDF' as const,
generateReport: generateReportPDF,
reportType: 'printablePdfV2',
requiresSavedState,
helpText: (
<FormattedMessage
id="reporting.printablePdfV2.helpText"
defaultMessage="Exports can take a few minutes to generate."
/>
),
generateReportButton: (
<FormattedMessage
id="reporting.printablePdfV2.generateButtonLabel"
data-test-subj="generateReportButton"
defaultMessage="Generate export"
/>
),
layoutOption: objectType === 'dashboard' ? ('print' as const) : undefined,
theme,
renderLayoutOptionSwitch: objectType === 'dashboard',
renderCopyURLButton: true,
absoluteUrl: new URL(relativePathPDF, window.location.href).toString(),
});
shareActions.push({
shareMenuItem: {
name: i18n.translate('reporting.shareContextMenu.ExportsButtonLabelPNG', {
defaultMessage: 'PNG export',
}),
toolTipContent: licenseToolTipContent,
disabled: licenseDisabled || sharingData.reportingDisabled,
['data-test-subj']: 'imageExports',
},
label: 'PNG' as const,
generateReport: generateReportPNG,
reportType: 'pngV2',
requiresSavedState,
helpText: (
<FormattedMessage
id="reporting.pngV2.helpText"
defaultMessage="Exports can take a few minutes to generate."
/>
),
generateReportButton: (
<FormattedMessage
id="reporting.pngV2.generateButtonLabel"
defaultMessage="Generate export"
data-test-subj="generateReportButton"
/>
),
layoutOption: objectType === 'dashboard' ? ('print' as const) : undefined,
renderCopyURLButton: true,
absoluteUrl: new URL(relativePathPNG, window.location.href).toString(),
});
return shareActions;
};
return {
id: 'modalImageReports',
getShareMenuItems,
};
};

View file

@ -7,7 +7,7 @@
*/
import { i18n } from '@kbn/i18n';
import { ShareContext, ShareMenuProvider } from '@kbn/share-plugin/public';
import { ShareContext, ShareMenuItem, ShareMenuProvider } from '@kbn/share-plugin/public';
import React from 'react';
import { ExportPanelShareOpts, JobParamsProviderOptions, ReportingSharingData } from '.';
import { ReportingAPIClient, checkLicense } from '../..';
@ -113,7 +113,7 @@ export const reportingScreenshotShareProvider = ({
}
const { sharingData } = shareOpts as unknown as { sharingData: ReportingSharingData };
const shareActions = [];
const shareActions: ShareMenuItem[] = [];
const pngPanelTitle = i18n.translate('reporting.share.contextMenu.pngReportsButtonLabel', {
defaultMessage: 'PNG Reports',

View file

@ -7,21 +7,19 @@
*/
import { CoreSetup } from '@kbn/core/public';
import React from 'react';
import { PDF_REPORT_TYPE, PDF_REPORT_TYPE_V2 } from '@kbn/reporting-export-types-pdf-common';
import { PDF_REPORT_TYPE_V2 } from '@kbn/reporting-export-types-pdf-common';
import { PNG_REPORT_TYPE_V2 } from '@kbn/reporting-export-types-png-common';
import { ReportingAPIClient } from '../../reporting_api_client';
import React from 'react';
import { ReportingAPIClient } from '../..';
import { ReportingPanelProps } from '../share_context_menu/reporting_panel_content';
import { ScreenCapturePanelContent } from '../share_context_menu/screen_capture_panel_content_lazy';
/**
* Properties for displaying a share menu with Reporting features.
*/
export interface ApplicationProps {
/**
* A function that Reporting calls to get the sharing data from the application.
* Needed for CSV exports and Canvas PDF reports.
*/
getJobParams: ReportingPanelProps['getJobParams'];
@ -41,25 +39,12 @@ export interface ApplicationProps {
onClose: () => void;
}
/**
* React components used to display share menus with Reporting features in an application.
*/
export interface ReportingPublicComponents {
/**
* An element to display a form to export the page as PDF
* @deprecated
*/
ReportingPanelPDF(props: ApplicationProps): JSX.Element;
/**
* An element to display a form to export the page as PDF
*/
ReportingPanelPDFV2(props: ApplicationProps): JSX.Element;
/**
* An element to display a form to export the page as PNG
*/
ReportingPanelPNGV2(props: ApplicationProps): JSX.Element;
/** Needed for Canvas PDF reports */
ReportingPanelPDFV2(props: ApplicationProps): JSX.Element | null;
ReportingPanelPNGV2(props: ApplicationProps): JSX.Element | undefined;
ReportingModalPDF(props: ApplicationProps): JSX.Element | undefined;
ReportingModalPNG(props: ApplicationProps): JSX.Element | undefined;
}
/**
@ -72,44 +57,79 @@ export function getSharedComponents(
apiClient: ReportingAPIClient
): ReportingPublicComponents {
return {
ReportingPanelPDF(props: ApplicationProps) {
return (
<ScreenCapturePanelContent
requiresSavedState={false}
reportType={PDF_REPORT_TYPE}
apiClient={apiClient}
toasts={core.notifications.toasts}
uiSettings={core.uiSettings}
theme={core.theme}
{...props}
/>
);
},
ReportingPanelPDFV2(props: ApplicationProps) {
return (
<ScreenCapturePanelContent
requiresSavedState={false}
reportType={PDF_REPORT_TYPE_V2}
apiClient={apiClient}
toasts={core.notifications.toasts}
uiSettings={core.uiSettings}
theme={core.theme}
{...props}
/>
);
const getJobParams = props.getJobParams as ReportingPanelProps['getJobParams'];
if (props.layoutOption === 'canvas') {
return (
<ScreenCapturePanelContent
requiresSavedState={false}
reportType={PDF_REPORT_TYPE_V2}
apiClient={apiClient}
toasts={core.notifications.toasts}
uiSettings={core.uiSettings}
theme={core.theme}
layoutOption={'canvas' as const}
{...props}
getJobParams={getJobParams}
/>
);
} else {
return null;
}
},
ReportingPanelPNGV2(props: ApplicationProps) {
return (
<ScreenCapturePanelContent
requiresSavedState={false}
reportType={PNG_REPORT_TYPE_V2}
apiClient={apiClient}
toasts={core.notifications.toasts}
uiSettings={core.uiSettings}
theme={core.theme}
{...props}
/>
);
const getJobParams = props.getJobParams as ReportingPanelProps['getJobParams'];
if (props.layoutOption === 'canvas') {
return (
<ScreenCapturePanelContent
requiresSavedState={false}
reportType={PNG_REPORT_TYPE_V2}
apiClient={apiClient}
toasts={core.notifications.toasts}
uiSettings={core.uiSettings}
theme={core.theme}
layoutOption={'canvas' as const}
{...props}
getJobParams={getJobParams}
/>
);
}
},
ReportingModalPDF(props: ApplicationProps) {
const getJobParams = props.getJobParams as ReportingPanelProps['getJobParams'];
if (props.layoutOption === 'canvas') {
return (
<ScreenCapturePanelContent
requiresSavedState={false}
reportType={PDF_REPORT_TYPE_V2}
apiClient={apiClient}
toasts={core.notifications.toasts}
uiSettings={core.uiSettings}
theme={core.theme}
layoutOption={'canvas' as const}
{...props}
getJobParams={getJobParams}
/>
);
}
},
ReportingModalPNG(props: ApplicationProps) {
const getJobParams = props.getJobParams as ReportingPanelProps['getJobParams'];
if (props.layoutOption === 'canvas') {
return (
<ScreenCapturePanelContent
requiresSavedState={false}
reportType={PDF_REPORT_TYPE_V2}
apiClient={apiClient}
toasts={core.notifications.toasts}
uiSettings={core.uiSettings}
theme={core.theme}
layoutOption={'canvas' as const}
{...props}
getJobParams={getJobParams}
/>
);
}
},
};
}

View file

@ -30,11 +30,6 @@
"@kbn/screenshotting-plugin",
"@kbn/i18n-react",
"@kbn/test-jest-helpers",
"@kbn/discover-utils",
"@kbn/saved-search-plugin",
"@kbn/discover-plugin",
"@kbn/embeddable-plugin",
"@kbn/ui-actions-plugin",
"@kbn/react-kibana-mount",
]
}

View file

@ -110,7 +110,7 @@ export function ModalContextProvider<T extends Array<ITabDeclaration<Record<stri
const reducersMap = useMemo(
() =>
tabs?.reduce((result, { reducer, initialState, ...rest }) => {
tabs.reduce((result, { reducer, initialState, ...rest }) => {
initialModalState.current[rest.id] = initialState ?? {};
// @ts-ignore
modalTabDefinitions.current.push({ ...rest });

View file

@ -67,7 +67,7 @@ describe('TabbedModal', () => {
expect(screen.queryByText(tabDefinition.name)).toBeInTheDocument();
userEvent.click(await screen.findByTestId(tabDefinition.modalActionBtn.dataTestSubj));
userEvent.click(await screen.findByTestId(tabDefinition.modalActionBtn!.dataTestSubj));
expect(mockedHandlerFn).toHaveBeenCalled();
});

View file

@ -52,7 +52,7 @@ export interface IModalTabDeclaration<S = {}> extends EuiTabProps, ITabDeclarati
description?: string;
'data-test-subj'?: string;
content: IModalTabContent<S>;
modalActionBtn: IModalTabActionBtn<S>;
modalActionBtn?: IModalTabActionBtn<S>;
}
export interface ITabbedModalInner extends Pick<ComponentProps<typeof EuiModal>, 'onClose'> {
@ -70,35 +70,33 @@ const TabbedModalInner: FC<ITabbedModalInner> = ({ onClose, modalTitle, modalWid
[selectedTabId, state]
);
const {
content: SelectedTabContent,
modalActionBtn: { handler, dataTestSubj, label, style },
} = useMemo(() => {
const { content: SelectedTabContent, modalActionBtn } = useMemo(() => {
return tabs.find((obj) => obj.id === selectedTabId)!;
}, [selectedTabId, tabs]);
const onSelectedTabChanged = (id: string) => {
dispatch({ type: 'META_selectedTabId', payload: id });
};
const onSelectedTabChanged = useCallback(
(id: string) => {
dispatch({ type: 'META_selectedTabId', payload: id });
},
[dispatch]
);
const renderTabs = () => {
return tabs.map((tab, index) => (
<EuiTab
key={index}
onClick={() => onSelectedTabChanged(tab.id)}
isSelected={tab.id === selectedTabId}
disabled={tab.disabled}
prepend={tab.prepend}
append={tab.append}
>
{tab.name}
</EuiTab>
));
};
const btnClickHandler = useCallback(() => {
handler({ state: selectedTabState });
}, [handler, selectedTabState]);
const renderTabs = useCallback(() => {
return tabs.map((tab, index) => {
return (
<EuiTab
key={index}
onClick={() => onSelectedTabChanged(tab.id)}
isSelected={tab.id === selectedTabId}
disabled={tab.disabled}
prepend={tab.prepend}
append={tab.append}
>
{tab.name}
</EuiTab>
);
});
}, [onSelectedTabChanged, selectedTabId, tabs]);
return (
<EuiModal
@ -118,17 +116,20 @@ const TabbedModalInner: FC<ITabbedModalInner> = ({ onClose, modalTitle, modalWid
})}
</Fragment>
</EuiModalBody>
<EuiModalFooter>
<EuiButton
isDisabled={style ? style({ state: selectedTabState }) : false}
fill
data-test-subj={dataTestSubj}
data-share-url={state.url}
onClick={btnClickHandler}
>
{label}
</EuiButton>
</EuiModalFooter>
{modalActionBtn?.id !== undefined && selectedTabState && (
<EuiModalFooter>
<EuiButton
fill
data-test-subj={modalActionBtn.dataTestSubj}
data-share-url={state.url}
onClick={() => {
modalActionBtn.handler({ state: selectedTabState });
}}
>
{modalActionBtn.label}
</EuiButton>
</EuiModalFooter>
)}
</EuiModal>
);
};

View file

@ -59,6 +59,7 @@ export function ShowShareModal({
},
},
},
notifications,
share: { toggleShareContextMenu },
} = pluginServices.getServices();
@ -197,5 +198,6 @@ export function ShowShareModal({
snapshotShareWarning: Boolean(unsavedDashboardState?.panels)
? shareModalStrings.getSnapshotShareWarning()
: undefined,
toasts: notifications.toasts,
});
}

View file

@ -129,7 +129,7 @@ export const getTopNavLinks = ({
isTextBased
);
const { locator } = services;
const { locator, notifications } = services;
const appState = state.appState.getState();
const { timefilter } = services.data.query.timefilter;
const timeRange = timefilter.getTime();
@ -198,6 +198,7 @@ export const getTopNavLinks = ({
onClose: () => {
anchorElement?.focus();
},
toasts: notifications.toasts,
});
},
};

View file

@ -7,6 +7,7 @@
*/
import { EuiButtonProps, EuiBetaBadgeProps } from '@elastic/eui';
import { InjectedIntl } from '@kbn/i18n-react';
export type TopNavMenuAction = (anchorElement: HTMLElement) => void;
@ -26,6 +27,7 @@ export interface TopNavMenuData {
iconSide?: EuiButtonProps['iconSide'];
target?: string;
href?: string;
intl?: InjectedIntl;
}
export interface RegisteredTopNavMenuData extends TopNavMenuData {

View file

@ -6,6 +6,8 @@
* Side Public License, v 1.
*/
import { ThemeServiceSetup } from '@kbn/core-theme-browser';
import { I18nStart } from '@kbn/core/public';
import { createContext, useContext } from 'react';
import { AnonymousAccessServiceContract } from '../../../common';
@ -16,6 +18,8 @@ import type {
ShareContext,
} from '../../types';
export type { ShareMenuItem } from '../../types';
export interface IShareContext extends ShareContext {
allowEmbed: boolean;
allowShortUrl: boolean;
@ -26,6 +30,8 @@ export interface IShareContext extends ShareContext {
snapshotShareWarning?: string;
objectTypeTitle?: string;
isEmbedded: boolean;
theme: ThemeServiceSetup;
i18n: I18nStart;
}
export const ShareTabsContext = createContext<IShareContext | null>(null);

View file

@ -136,6 +136,7 @@ export class ShareContextMenu extends Component<ShareContextMenuProps> {
});
menuItems.push({
...shareMenuItem,
name: shareMenuItem!.name,
panel: panelId,
});
});

View file

@ -10,7 +10,7 @@ import React, { type FC } from 'react';
import { TabbedModal } from '@kbn/shared-ux-tabbed-modal';
import { ShareTabsContext, useShareTabsContext, type IShareContext } from './context';
import { linkTab, embedTab } from './tabs';
import { linkTab, embedTab, exportTab } from './tabs';
export const ShareMenuV2: FC<{ shareContext: IShareContext }> = ({ shareContext }) => {
return (
@ -28,20 +28,28 @@ export const ShareMenuTabs = () => {
return null;
}
const { allowEmbed, objectType, onClose } = shareContext;
const { allowEmbed, objectType, onClose, shareMenuItems } = shareContext;
const tabs = [];
tabs.push(linkTab);
if (shareMenuItems.length > 0) {
tabs.push(exportTab);
}
if (allowEmbed) {
tabs.push(embedTab);
}
const formattedTitle =
objectType === 'lens' ? `Share this Lens visualization` : `Share this ${objectType}`;
return (
<TabbedModal
tabs={tabs}
modalWidth={483}
modalWidth={498}
onClose={onClose}
modalTitle={`Share this ${objectType}`}
modalTitle={formattedTitle}
defaultSelectedTabId="link"
/>
);

View file

@ -6,7 +6,16 @@
* Side Public License, v 1.
*/
import { EuiForm, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui';
import {
EuiButton,
EuiFlexGroup,
EuiForm,
EuiFormRow,
EuiSpacer,
EuiText,
EuiFlexItem,
EuiCopy,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, useEffect, useState } from 'react';
import useMountedState from 'react-use/lib/useMountedState';
@ -24,7 +33,7 @@ type EmbedProps = Pick<
| 'embedUrlParamExtensions'
| 'objectType'
> & {
onChange: (url: string) => void;
setIsNotSaved: () => void;
};
interface UrlParams {
@ -43,8 +52,8 @@ export const EmbedContent = ({
shareableUrlForSavedObject,
shareableUrl,
isEmbedded,
onChange,
objectType,
setIsNotSaved,
}: EmbedProps) => {
const isMounted = useMountedState();
const [urlParams, setUrlParams] = useState<UrlParams | undefined>(undefined);
@ -56,8 +65,8 @@ export const EmbedContent = ({
const [usePublicUrl] = useState<boolean>(false);
useEffect(() => {
onChange(url);
}, [url, onChange]);
if (objectType !== 'dashboard') setIsNotSaved();
}, [url, setIsNotSaved, objectType]);
const getUrlParamExtensions = useCallback(
(tempUrl: string): string => {
@ -224,7 +233,7 @@ export const EmbedContent = ({
const helpText =
objectType === 'dashboard' ? (
<FormattedMessage
id="share.embed.dashbord.helpText"
id="share.embed.dashboard.helpText"
defaultMessage="Embed this dashboard into another webpage. Select which items to include in the embeddable view."
/>
) : (
@ -236,12 +245,33 @@ export const EmbedContent = ({
);
return (
<EuiForm>
<EuiSpacer size="m" />
<EuiText size="s">{helpText}</EuiText>
<EuiSpacer />
{renderUrlParamExtensions()}
<EuiSpacer />
</EuiForm>
<>
<EuiForm>
<EuiSpacer size="m" />
<EuiText size="s">{helpText}</EuiText>
<EuiSpacer />
{renderUrlParamExtensions()}
<EuiSpacer />
</EuiForm>
<EuiFlexGroup justifyContent="flexEnd" responsive={false}>
<EuiFlexItem grow={false}>
<EuiCopy textToCopy={url}>
{(copy) => (
<EuiButton
data-test-subj="copyEmbedUrlButton"
onClick={copy}
data-share-url={url}
fill
>
<FormattedMessage
id="share.link.copyEmbedCodeButton"
defaultMessage="Copy Embed Code"
/>
</EuiButton>
)}
</EuiCopy>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};

View file

@ -8,47 +8,45 @@
import { i18n } from '@kbn/i18n';
import React, { useCallback } from 'react';
import { copyToClipboard } from '@elastic/eui';
import { type IModalTabDeclaration } from '@kbn/shared-ux-tabbed-modal';
import { EmbedContent } from './embed_content';
import { useShareTabsContext } from '../../context';
const EMBED_TAB_ACTIONS = {
SET_EMBED_URL: 'SET_EMBED_URL',
SET_IS_NOT_SAVED: 'SET_IS_NOT_SAVED',
};
type IEmbedTab = IModalTabDeclaration<{ url: string }>;
type IEmbedTab = IModalTabDeclaration<{ url: string; isNotSaved: boolean }>;
const embedTabReducer: IEmbedTab['reducer'] = (state = { url: '' }, action) => {
const embedTabReducer: IEmbedTab['reducer'] = (state = { url: '', isNotSaved: false }, action) => {
switch (action.type) {
case EMBED_TAB_ACTIONS.SET_EMBED_URL:
case EMBED_TAB_ACTIONS.SET_IS_NOT_SAVED:
return {
...state,
url: action.payload,
isNotSaved: action.payload,
};
default:
return state;
}
};
const EmbedTabContent: NonNullable<IEmbedTab['content']> = ({ dispatch }) => {
const EmbedTabContent: NonNullable<IEmbedTab['content']> = ({ state, dispatch }) => {
const {
embedUrlParamExtensions,
shareableUrlForSavedObject,
shareableUrl,
isEmbedded,
objectType,
isDirty,
} = useShareTabsContext()!;
const onChange = useCallback(
(shareUrl: string) => {
dispatch({
type: EMBED_TAB_ACTIONS.SET_EMBED_URL,
payload: shareUrl,
});
},
[dispatch]
);
const setIsNotSaved = useCallback(() => {
dispatch({
type: EMBED_TAB_ACTIONS.SET_IS_NOT_SAVED,
payload: objectType === 'dashboard' ? isDirty : false,
});
}, [dispatch, objectType, isDirty]);
return (
<EmbedContent
@ -58,8 +56,9 @@ const EmbedTabContent: NonNullable<IEmbedTab['content']> = ({ dispatch }) => {
shareableUrl,
isEmbedded,
objectType,
isNotSaved: state?.isNotSaved,
setIsNotSaved,
}}
onChange={onChange}
/>
);
};
@ -75,14 +74,4 @@ export const embedTab: IEmbedTab = {
}),
reducer: embedTabReducer,
content: EmbedTabContent,
modalActionBtn: {
id: 'embed',
dataTestSubj: 'copyEmbedUrlButton',
label: i18n.translate('share.link.copyEmbedCodeButton', {
defaultMessage: 'Copy Embed',
}),
handler: ({ state }) => {
copyToClipboard(state.url);
},
},
};

View file

@ -0,0 +1,290 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n-react';
import {
EuiButton,
EuiButtonEmpty,
EuiCopy,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiIcon,
EuiRadioGroup,
EuiSpacer,
EuiSwitch,
EuiSwitchEvent,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import useMountedState from 'react-use/lib/useMountedState';
import { ShareMenuItem } from '../../../types';
import { type IShareContext } from '../../context';
type ExportProps = Pick<IShareContext, 'isDirty' | 'objectId' | 'objectType' | 'onClose'> & {
layoutOption?: 'print';
aggregateReportTypes: ShareMenuItem[];
intl: InjectedIntl;
};
type AllowedExports = 'pngV2' | 'printablePdfV2' | 'csv_v2' | 'csv_searchsource' | 'lens_csv';
const ExportContentUi = ({ isDirty, objectType, aggregateReportTypes, intl }: ExportProps) => {
// needed for CSV in Discover
const firstRadio =
(aggregateReportTypes[0].reportType as AllowedExports) ?? ('printablePdfV2' as const);
const [, setIsStale] = useState(false);
const [isCreatingReport, setIsCreatingReport] = useState<boolean>(false);
const [selectedRadio, setSelectedRadio] = useState<AllowedExports>(firstRadio);
const [usePrintLayout, setPrintLayout] = useState(false);
const isMounted = useMountedState();
const markAsStale = useCallback(() => {
if (!isMounted) return;
setIsStale(true);
}, [isMounted]);
const getProperties = useCallback(() => {
if (objectType === 'search') {
return aggregateReportTypes[0];
} else {
return aggregateReportTypes?.filter(({ reportType }) => reportType === selectedRadio)[0];
}
}, [selectedRadio, aggregateReportTypes, objectType]);
const handlePrintLayoutChange = useCallback(
(evt: EuiSwitchEvent) => {
setPrintLayout(evt.target.checked);
getProperties();
},
[setPrintLayout, getProperties]
);
const {
generateReportButton,
helpText,
renderCopyURLButton,
generateReport,
generateReportForPrinting,
downloadCSVLens,
absoluteUrl,
renderLayoutOptionSwitch,
} = getProperties();
const getRadioOptions = useCallback(() => {
if (!aggregateReportTypes.length) {
throw new Error('No content registered for this tab');
}
return aggregateReportTypes.map(({ reportType, label }) => {
if (reportType == null) {
throw new Error('expected reportType to be string!');
}
return { id: reportType, label, 'data-test-subj': `${reportType}-radioOption` };
});
}, [aggregateReportTypes]);
const renderLayoutOptionsSwitch = useCallback(() => {
if (renderLayoutOptionSwitch) {
return (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiSwitch
label={
<EuiText size="s" css={{ textWrap: 'nowrap' }}>
<FormattedMessage
id="share.screenCapturePanelContent.optimizeForPrintingLabel"
defaultMessage="For printing"
/>
</EuiText>
}
checked={usePrintLayout}
onChange={handlePrintLayoutChange}
data-test-subj="usePrintLayout"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiToolTip
content={
<FormattedMessage
id="share.screenCapturePanelContent.optimizeForPrintingHelpText"
defaultMessage="Uses multiple pages, showing at most 2 visualizations per page "
/>
}
>
<EuiIcon type="questionInCircle" />
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
);
}
}, [usePrintLayout, renderLayoutOptionSwitch, handlePrintLayoutChange]);
useEffect(() => {
isMounted();
getRadioOptions();
renderLayoutOptionsSwitch();
getProperties();
markAsStale();
}, [
aggregateReportTypes,
getProperties,
getRadioOptions,
renderLayoutOptionsSwitch,
markAsStale,
isMounted,
]);
const showCopyURLButton = useCallback(() => {
if (renderCopyURLButton)
return (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiToolTip
content={
isDirty ? (
<FormattedMessage
id="share.modalContent.unsavedStateErrorText"
defaultMessage="Save your work before copying this URL."
/>
) : (
<FormattedMessage
id="share.modalContent.savedStateErrorText"
defaultMessage="Copy this POST URL to call generation from outside Kibana or from Watcher."
/>
)
}
>
<EuiCopy textToCopy={absoluteUrl ?? ''}>
{(copy) => (
<EuiButtonEmpty
iconType="copy"
flush="both"
onClick={copy}
data-test-subj="shareReportingCopyURL"
>
<EuiToolTip
id="share.savePostURLMessage"
content="Unsaved changes. This URL will not reflect later saved changes unless you save."
>
<FormattedMessage
id="share.modalContent.copyUrlButtonLabel"
defaultMessage="Post URL"
/>
</EuiToolTip>
</EuiButtonEmpty>
)}
</EuiCopy>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
content={
<FormattedMessage
id="share.postURLWatcherMessage"
defaultMessage="Copy this POST URL to call generation from outside Kibana or from Watcher. Unsaved changes: URL may change if you upgrade Kibana"
/>
}
>
<EuiIcon type="questionInCircle" />
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
);
}, [absoluteUrl, isDirty, renderCopyURLButton]);
const getReport = useCallback(() => {
if (!generateReportForPrinting && !generateReport && !downloadCSVLens) {
throw new Error('Report cannot be run due to no generate report method registered');
}
if (objectType === 'lens' && selectedRadio === 'lens_csv') {
return downloadCSVLens!();
}
return usePrintLayout ? generateReportForPrinting!({ intl }) : generateReport!({ intl });
}, [
downloadCSVLens,
generateReport,
generateReportForPrinting,
objectType,
selectedRadio,
usePrintLayout,
intl,
]);
const renderGenerateReportButton = useCallback(() => {
return (
<EuiButton
fill
color="primary"
onClick={() => {
setIsCreatingReport(true);
getReport();
setIsCreatingReport(false);
}}
data-test-subj="generateReportButton"
isLoading={Boolean(isCreatingReport)}
>
{generateReportButton}
</EuiButton>
);
}, [generateReportButton, getReport, isCreatingReport]);
const renderRadioOptions = () => {
if (getRadioOptions().length > 1) {
return (
<EuiFlexGroup direction="row" justifyContent={'spaceBetween'}>
<EuiRadioGroup
options={getRadioOptions()}
onChange={(id) => {
setSelectedRadio(id as AllowedExports);
getProperties();
}}
name="image reporting radio group"
idSelected={selectedRadio}
legend={{
children: <FormattedMessage id="share.fileType" defaultMessage="File type" />,
}}
/>
</EuiFlexGroup>
);
}
};
const getHelpText = () => {
if (objectType === 'lens' && generateReport !== undefined) {
return helpText;
} else {
return (
<FormattedMessage
id="share.helpText.goldLicense.roleNotPDFPNG"
defaultMessage="Export a CSV of this visualization."
/>
);
}
};
return (
<>
<EuiForm>
<EuiSpacer size="l" />
{getHelpText()}
<EuiSpacer size="m" />
{renderRadioOptions()}
<EuiSpacer size="xl" />
</EuiForm>
<EuiFlexGroup justifyContent="flexEnd" responsive={false}>
{renderLayoutOptionsSwitch()}
{showCopyURLButton()}
{renderGenerateReportButton()}
</EuiFlexGroup>
</>
);
};
export const ExportContent = injectI18n(ExportContentUi);

View file

@ -0,0 +1,36 @@
/*
* 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 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 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, type ShareMenuItem } from '../../context';
type IExportTab = IModalTabDeclaration;
const ExportTabContent = () => {
const { shareMenuItems, objectType, isDirty, onClose } = useShareTabsContext()!;
return (
<ExportContent
objectType={objectType}
isDirty={isDirty}
onClose={onClose}
aggregateReportTypes={shareMenuItems as unknown as ShareMenuItem[]}
/>
);
};
export const exportTab: IExportTab = {
id: 'export',
name: i18n.translate('share.contextMenu.exportCodeTab', {
defaultMessage: 'Export',
}),
content: ExportTabContent,
};

View file

@ -8,3 +8,4 @@
export { linkTab } from './link';
export { embedTab } from './embed';
export { exportTab } from './export';

View file

@ -8,7 +8,6 @@
import React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { copyToClipboard } from '@elastic/eui';
import { type IModalTabDeclaration } from '@kbn/shared-ux-tabbed-modal';
import { useShareTabsContext } from '../../context';
import { LinkContent } from './link_content';
@ -16,6 +15,7 @@ import { LinkContent } from './link_content';
type ILinkTab = IModalTabDeclaration<{
dashboardUrl: string;
isNotSaved: boolean;
setIsClicked: boolean;
}>;
const LINK_TAB_ACTIONS = {
@ -27,6 +27,7 @@ const linkTabReducer: ILinkTab['reducer'] = (
state = {
dashboardUrl: '',
isNotSaved: false,
setIsClicked: false,
},
action
) => {
@ -51,11 +52,11 @@ const LinkTabContent: ILinkTab['content'] = ({ state, dispatch }) => {
objectType,
objectId,
isDirty,
isEmbedded,
shareableUrl,
shareableUrlForSavedObject,
urlService,
shareableUrlLocatorParams,
allowShortUrl,
} = useShareTabsContext()!;
const setDashboardLink = useCallback(
@ -68,9 +69,17 @@ const LinkTabContent: ILinkTab['content'] = ({ state, dispatch }) => {
const setIsNotSaved = useCallback(() => {
dispatch({
type: LINK_TAB_ACTIONS.SET_IS_NOT_SAVED,
payload: objectType === 'lens' ? isDirty : false,
payload:
objectType === 'lens' || (objectType === 'dashboard' && !allowShortUrl) ? isDirty : false,
});
}, [dispatch, objectType, isDirty]);
}, [dispatch, objectType, isDirty, allowShortUrl]);
const setIsClicked = useCallback(() => {
dispatch({
type: LINK_TAB_ACTIONS.SET_IS_NOT_SAVED,
payload: setIsClicked,
});
}, [dispatch]);
return (
<LinkContent
@ -78,15 +87,16 @@ const LinkTabContent: ILinkTab['content'] = ({ state, dispatch }) => {
objectType,
objectId,
isDirty,
isEmbedded,
shareableUrl,
shareableUrlForSavedObject,
urlService,
shareableUrlLocatorParams,
dashboardLink: state.dashboardUrl,
isNotSaved: state.isNotSaved,
dashboardLink: state?.dashboardUrl,
setDashboardLink,
isNotSaved: state?.isNotSaved,
setIsNotSaved,
allowShortUrl,
setIsClicked: state?.setIsClicked,
}}
/>
);
@ -102,13 +112,4 @@ export const linkTab: ILinkTab = {
}),
content: LinkTabContent,
reducer: linkTabReducer,
modalActionBtn: {
id: 'link',
dataTestSubj: 'copyShareUrlButton',
label: i18n.translate('share.link.copyLinkButton', { defaultMessage: 'Copy link' }),
handler: ({ state }) => {
copyToClipboard(state.dashboardUrl);
},
style: ({ state }) => state.isNotSaved,
},
};

View file

@ -6,10 +6,20 @@
* Side Public License, v 1.
*/
import { EuiCodeBlock, EuiForm, EuiSpacer, EuiText } from '@elastic/eui';
import {
copyToClipboard,
EuiButton,
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiSpacer,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, useEffect, useState } from 'react';
import useMountedState from 'react-use/lib/useMountedState';
import React, { useCallback, useState } from 'react';
import { format as formatUrl, parse as parseUrl } from 'url';
import { IShareContext } from '../../context';
@ -18,15 +28,12 @@ type LinkProps = Pick<
| 'objectType'
| 'objectId'
| 'isDirty'
| 'isEmbedded'
| 'urlService'
| 'shareableUrl'
| 'shareableUrlForSavedObject'
| 'shareableUrlLocatorParams'
> & {
setDashboardLink: (url: string) => void;
setIsNotSaved: () => void;
};
| 'allowShortUrl'
>;
interface UrlParams {
[extensionName: string]: {
@ -38,39 +45,20 @@ export const LinkContent = ({
objectType,
objectId,
isDirty,
isEmbedded,
shareableUrl,
shareableUrlForSavedObject,
urlService,
shareableUrlLocatorParams,
setDashboardLink,
setIsNotSaved,
allowShortUrl,
}: LinkProps) => {
const isMounted = useMountedState();
const [url, setUrl] = useState<string>('');
const [urlParams] = useState<UrlParams | undefined>(undefined);
const [isTextCopied, setTextCopied] = useState(false);
const [shortUrlCache, setShortUrlCache] = useState<string | undefined>(undefined);
useEffect(() => {
// propagate url updates upwards to tab
setDashboardLink(url);
setIsNotSaved();
}, [setDashboardLink, url, setIsNotSaved, isDirty]);
const isNotSaved = useCallback(() => {
return objectId === undefined || objectId === '' || isDirty;
}, [objectId, isDirty]);
const makeUrlEmbeddable = useCallback((tempUrl: string): string => {
const embedParam = '?embed=true';
const urlHasQueryString = tempUrl.indexOf('?') !== -1;
if (urlHasQueryString) {
return tempUrl.replace('?', `${embedParam}&`);
}
return `${tempUrl}${embedParam}`;
}, []);
return isDirty;
}, [isDirty]);
const getUrlParamExtensions = useCallback(
(tempUrl: string): string => {
@ -93,12 +81,11 @@ export const LinkContent = ({
const updateUrlParams = useCallback(
(tempUrl: string) => {
tempUrl = isEmbedded ? makeUrlEmbeddable(tempUrl) : tempUrl;
tempUrl = urlParams ? getUrlParamExtensions(tempUrl) : tempUrl;
setUrl(tempUrl);
return tempUrl;
},
[makeUrlEmbeddable, getUrlParamExtensions, urlParams, isEmbedded]
[getUrlParamExtensions, urlParams]
);
const getSnapshotUrl = useCallback(
@ -148,56 +135,112 @@ export const LinkContent = ({
return updateUrlParams(formattedUrl);
}, [getSnapshotUrl, isNotSaved, updateUrlParams]);
const createShortUrl = useCallback(
async (tempUrl: string) => {
if (!isMounted || shortUrlCache) return;
const shortUrl = shareableUrlLocatorParams
? await urlService.shortUrls.get(null).createWithLocator(shareableUrlLocatorParams)
: (await urlService.shortUrls.get(null).createFromLongUrl(tempUrl)).url;
setShortUrlCache(shortUrl as string);
setUrl(shortUrl as string);
},
[isMounted, shareableUrlLocatorParams, urlService.shortUrls, shortUrlCache]
);
const setUrlHelper = useCallback(() => {
let tempUrl: string | undefined;
if (objectType === 'dashboard' || objectType === 'search') {
tempUrl = getSnapshotUrl();
const createShortUrl = useCallback(async () => {
if (shareableUrlLocatorParams) {
const shortUrls = urlService.shortUrls.get(null);
const shortUrl = await shortUrls.createWithLocator(shareableUrlLocatorParams);
const urlWithLoc = await shortUrl.locator.getUrl(shortUrl.params, { absolute: true });
setShortUrlCache(urlWithLoc);
return urlWithLoc;
} else {
tempUrl = getSavedObjectUrl();
const snapshotUrl = getSnapshotUrl();
const shortUrl = await urlService.shortUrls.get(null).createFromLongUrl(snapshotUrl);
setShortUrlCache(shortUrl.url);
return shortUrl.url;
}
return url === '' || objectType === 'lens' ? setUrl(tempUrl!) : createShortUrl(tempUrl!);
}, [getSavedObjectUrl, getSnapshotUrl, createShortUrl, objectType, url]);
}, [shareableUrlLocatorParams, urlService.shortUrls, getSnapshotUrl, setShortUrlCache]);
useEffect(() => {
isMounted();
setUrlHelper();
}, [isMounted, setUrlHelper]);
const copyUrlHelper = useCallback(async () => {
let urlToCopy = url;
if (!urlToCopy) {
let tempUrl = '';
if (objectType === 'dashboard' || objectType === 'search') {
tempUrl = getSnapshotUrl();
} else if (objectType === 'lens') {
tempUrl = getSavedObjectUrl() as string;
}
urlToCopy = allowShortUrl ? await createShortUrl() : tempUrl;
}
setUrl(() => {
copyToClipboard(urlToCopy);
setTextCopied(true);
return urlToCopy;
});
}, [allowShortUrl, createShortUrl, getSavedObjectUrl, getSnapshotUrl, objectType, setUrl, url]);
const renderSaveState =
objectType === 'lens' && isNotSaved() ? (
<FormattedMessage
id="share.link.lens.saveUrlBox"
defaultMessage="There are unsaved changes. Before you generate a link, save the {objectType}."
values={{ objectType }}
/>
) : objectType === 'lens' ? (
shortUrlCache ?? shareableUrl
) : (
shareableUrl ?? shortUrlCache ?? ''
);
const lensOnClick = () => {
if (objectType === 'lens' && !isDirty) {
return copyUrlHelper();
} else {
return copyUrlHelper();
}
};
return (
<EuiForm>
<EuiSpacer size="m" />
<EuiText size="s">
<FormattedMessage
id="share.link.helpText"
defaultMessage="Share a direct link to this {objectType}."
values={{ objectType }}
/>
</EuiText>
<EuiSpacer size="l" />
<EuiCodeBlock whiteSpace="pre" css={{ paddingRight: '30px' }}>
{objectType === 'lens' && isNotSaved() ? (
<>
<EuiForm>
<EuiSpacer size="m" />
<EuiText size="s">
<FormattedMessage
id="share.link.lens.saveUrlBox"
defaultMessage="There are unsaved changes. Before you generate a link, save the lens."
id="share.link.helpText"
defaultMessage="Share a direct link to this {objectType}."
values={{ objectType }}
/>
) : (
shareableUrl ?? url ?? ''
</EuiText>
<EuiSpacer size="l" />
{objectType !== 'dashboard' && (
<EuiCodeBlock whiteSpace="pre" css={{ paddingRight: '30px' }}>
{renderSaveState}
</EuiCodeBlock>
)}
</EuiCodeBlock>
<EuiSpacer />
</EuiForm>
<EuiSpacer />
</EuiForm>
<EuiFlexGroup justifyContent="flexEnd" responsive={false}>
<EuiFlexItem grow={false}>
<EuiToolTip
content={
isDirty && objectType === 'lens'
? i18n.translate('share.link.unsaved', {
defaultMessage:
'There are unsaved changes. Before you generate a link, save the {objectType}.',
values: {
objectType,
},
})
: isTextCopied
? i18n.translate('share.link.copied', { defaultMessage: 'Text copied' })
: null
}
>
<EuiButton
fill
data-test-subj="copyShareUrlButton"
data-share-url={url}
onBlur={() => (objectType === 'lens' && isDirty ? null : setTextCopied(false))}
onClick={lensOnClick}
disabled={objectType === 'lens' && isDirty}
>
<FormattedMessage id="share.link.copyLinkButton" defaultMessage="Copy link" />
</EuiButton>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};

View file

@ -42,6 +42,7 @@ const createSetupContract = (): Setup => {
url,
navigate: jest.fn(),
setAnonymousAccessServiceProvider: jest.fn(),
isNewVersion: jest.fn(),
};
return setupContract;
};

View file

@ -42,6 +42,11 @@ export type SharePublicSetup = ShareMenuRegistrySetup & {
* Sets the provider for the anonymous access service; this is consumed by the Security plugin to avoid a circular dependency.
*/
setAnonymousAccessServiceProvider: (provider: () => AnonymousAccessServiceContract) => void;
/**
* Allows for canvas to register the older versioned way whereas reporting for Discover/Lens/Dashboard
* can use the new share version and show the share context modals
*/
isNewVersion: () => boolean;
};
/** @public */
@ -74,7 +79,7 @@ export class SharePlugin
>
{
private config: ClientConfigType;
private readonly shareMenuRegistry = new ShareMenuRegistry();
private readonly shareMenuRegistry?: ShareMenuRegistry;
private readonly shareContextMenu = new ShareMenuManager();
private redirectManager?: RedirectManager;
private url?: BrowserUrlService;
@ -82,6 +87,9 @@ export class SharePlugin
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = initializerContext.config.get<ClientConfigType>();
this.shareMenuRegistry = new ShareMenuRegistry({
newVersionEnabled: this.config.new_version.enabled,
});
}
public setup(core: CoreSetup): SharePublicSetup {
@ -126,7 +134,7 @@ export class SharePlugin
registrations.setup({ analytics });
return {
...this.shareMenuRegistry.setup(),
...this.shareMenuRegistry!.setup(),
url: this.url,
navigate: (options: RedirectOptions) => this.redirectManager!.navigate(options),
setAnonymousAccessServiceProvider: (provider: () => AnonymousAccessServiceContract) => {
@ -135,6 +143,7 @@ export class SharePlugin
}
this.anonymousAccessServiceProvider = provider;
},
isNewVersion: () => this.config.new_version.enabled,
};
}
@ -143,7 +152,7 @@ export class SharePlugin
const sharingContextMenuStart = this.shareContextMenu.start(
core,
this.url!,
this.shareMenuRegistry.start(),
this.shareMenuRegistry!.start(),
disableEmbed,
this.config.new_version.enabled ?? false,
this.anonymousAccessServiceProvider

View file

@ -9,10 +9,9 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { CoreStart, OverlayStart, ThemeServiceStart } from '@kbn/core/public';
import { CoreStart, OverlayStart, ThemeServiceStart, ToastsSetup } from '@kbn/core/public';
import { EuiWrappingPopover } from '@elastic/eui';
import { I18nProvider } from '@kbn/i18n-react';
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import { ShareMenuItem, ShowShareMenuOptions } from '../types';
import { ShareMenuRegistryStart } from './share_menu_registry';
@ -58,6 +57,7 @@ export class ShareMenuManager {
overlays: core.overlays,
i18n: core.i18n,
newVersionEnabled,
toasts: core.notifications.toasts,
});
},
};
@ -92,6 +92,7 @@ export class ShareMenuManager {
i18n,
isDirty,
newVersionEnabled,
toasts,
}: ShowShareMenuOptions & {
anchorElement: HTMLElement;
menuItems: ShareMenuItem[];
@ -103,6 +104,7 @@ export class ShareMenuManager {
i18n: CoreStart['i18n'];
isDirty: boolean;
newVersionEnabled: boolean;
toasts: ToastsSetup;
}) {
if (this.isOpen) {
onClose();
@ -176,6 +178,9 @@ export class ShareMenuManager {
onClose();
session.close();
},
theme,
i18n,
toasts,
}}
/>,
{ i18n, theme }

View file

@ -12,18 +12,18 @@ import { ShareMenuItem, ShareContext } from '../types';
describe('ShareActionsRegistry', () => {
describe('setup', () => {
test('throws when registering duplicate id', () => {
const setup = new ShareMenuRegistry().setup();
const setup = new ShareMenuRegistry({ newVersionEnabled: false }).setup();
setup.register({
id: 'myTest',
id: 'csvReports',
getShareMenuItems: () => [],
});
expect(() =>
setup.register({
id: 'myTest',
id: 'csvReports',
getShareMenuItems: () => [],
})
).toThrowErrorMatchingInlineSnapshot(
`"Share menu provider with id [myTest] has already been registered. Use a unique id."`
`"Share menu provider with id [csvReports] has already been registered. Use a unique id."`
);
});
});
@ -31,7 +31,7 @@ describe('ShareActionsRegistry', () => {
describe('start', () => {
describe('getActions', () => {
test('returns a flat list of actions returned by all providers', () => {
const service = new ShareMenuRegistry();
const service = new ShareMenuRegistry({ newVersionEnabled: false });
const registerFunction = service.setup().register;
const shareAction1 = {} as ShareMenuItem;
const shareAction2 = {} as ShareMenuItem;
@ -39,11 +39,11 @@ describe('ShareActionsRegistry', () => {
const provider1Callback = jest.fn(() => [shareAction1]);
const provider2Callback = jest.fn(() => [shareAction2, shareAction3]);
registerFunction({
id: 'myTest',
id: 'csvReports',
getShareMenuItems: provider1Callback,
});
registerFunction({
id: 'myTest2',
id: 'screenCaptureReports',
getShareMenuItems: provider2Callback,
});
const context = {} as ShareContext;

View file

@ -10,6 +10,11 @@ import { ShareContext, ShareMenuProvider } from '../types';
export class ShareMenuRegistry {
private readonly shareMenuProviders = new Map<string, ShareMenuProvider>();
newVersionEnabled: boolean;
constructor({ newVersionEnabled }: { newVersionEnabled: boolean }) {
this.newVersionEnabled = newVersionEnabled;
}
public setup() {
return {
@ -21,13 +26,25 @@ export class ShareMenuRegistry {
* @param shareMenuProvider
*/
register: (shareMenuProvider: ShareMenuProvider) => {
if (this.shareMenuProviders.has(shareMenuProvider.id)) {
throw new Error(
`Share menu provider with id [${shareMenuProvider.id}] has already been registered. Use a unique id.`
);
if (
!this.newVersionEnabled &&
(shareMenuProvider.id === 'csvReports' ||
shareMenuProvider.id === 'screenCaptureReports' ||
shareMenuProvider.id === 'csvDownloadLens')
) {
if (this.shareMenuProviders.has(shareMenuProvider.id)) {
throw new Error(
`Share menu provider with id [${shareMenuProvider.id}] has already been registered. Use a unique id.`
);
}
this.shareMenuProviders.set(shareMenuProvider.id, shareMenuProvider);
} else if (
shareMenuProvider.id === 'csvReportsModal' ||
shareMenuProvider.id === 'modalImageReports' ||
shareMenuProvider.id === 'csvDownloadLens'
) {
this.shareMenuProviders.set(shareMenuProvider.id, shareMenuProvider);
}
this.shareMenuProviders.set(shareMenuProvider.id, shareMenuProvider);
},
};
}

View file

@ -6,10 +6,10 @@
* Side Public License, v 1.
*/
import { ComponentType } from 'react';
import { ComponentType, ReactElement } from 'react';
import { EuiContextMenuPanelDescriptor } from '@elastic/eui';
import { EuiContextMenuPanelItemDescriptorEntry } from '@elastic/eui/src/components/context_menu/context_menu';
import type { Capabilities } from '@kbn/core/public';
import type { Capabilities, ThemeServiceSetup, ToastsSetup } from '@kbn/core/public';
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';
@ -51,6 +51,7 @@ export interface ShareContext {
onClose: () => void;
showPublicUrlSwitch?: (anonymousUserCapabilities: Capabilities) => boolean;
disabledShareUrl?: boolean;
toasts: ToastsSetup;
}
/**
@ -71,10 +72,27 @@ export interface ShareContextMenuPanelItem
* directly in the context menu. If the item is clicked, the `panel` is shown.
* */
export interface ShareMenuItem {
shareMenuItem: ShareContextMenuPanelItem;
panel: EuiContextMenuPanelDescriptor;
shareMenuItem?: ShareContextMenuPanelItem;
// needed for Canvas
panel?: EuiContextMenuPanelDescriptor;
label?: 'PDF' | 'CSV' | 'PNG';
reportType?: string;
requiresSavedState?: boolean;
helpText?: ReactElement;
copyURLButton?: { id: string; dataTestSubj: string; label: string };
generateReportButton?: ReactElement;
generateReport?: Function;
generateReportForPrinting?: Function;
theme?: ThemeServiceSetup;
downloadCSVLens?: Function;
renderLayoutOptionSwitch?: boolean;
layoutOption?: 'print';
absoluteUrl?: string;
generateCopyUrl?: URL;
renderCopyURLButton?: boolean;
}
type ShareMenuItemType = Omit<ShareMenuItem, 'intl'>;
/**
* @public
* A source for additional menu items shown in the share context menu. Any provider
@ -84,8 +102,7 @@ export interface ShareMenuItem {
* */
export interface ShareMenuProvider {
readonly id: string;
getShareMenuItems: (context: ShareContext) => ShareMenuItem[];
getShareMenuItems: (context: ShareContext) => ShareMenuItemType[];
}
interface UrlParamExtensionProps {

View file

@ -20,6 +20,7 @@
"@kbn/shared-ux-prompt-not-found",
"@kbn/react-kibana-mount",
"@kbn/shared-ux-tabbed-modal",
"@kbn/core-theme-browser",
],
"exclude": [
"target/**/*",

View file

@ -7,7 +7,7 @@
*/
import { EuiFlexGroup, useEuiTheme } from '@elastic/eui';
import { InjectedIntl, injectI18n } from '@kbn/i18n-react';
import { injectI18n } from '@kbn/i18n-react';
import type { Filter } from '@kbn/es-query';
import React, { ReactNode, useRef } from 'react';
import { DataView } from '@kbn/data-views-plugin/public';
@ -21,7 +21,6 @@ export interface Props {
onFiltersUpdated?: (filters: Filter[]) => void;
className?: string;
indexPatterns: DataView[];
intl: InjectedIntl;
timeRangeForSuggestionsOverride?: boolean;
filtersForSuggestions?: Filter[];
hiddenPanelOptions?: FilterItemsProps['hiddenPanelOptions'];

View file

@ -15,6 +15,7 @@ import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { switchMap } from 'rxjs';
import { getManagedContentBadge } from '@kbn/managed-content-badge';
import { InjectedIntl, injectI18n } from '@kbn/i18n-react';
import type {
VisualizeServices,
VisualizeAppState,
@ -60,7 +61,7 @@ const TopNav = ({
embeddableId,
onAppLeave,
eventEmitter,
}: VisualizeTopNavProps) => {
}: VisualizeTopNavProps & { intl: InjectedIntl }) => {
const { services } = useKibana<VisualizeServices>();
const { TopNavMenu } = services.navigation.ui;
const { setHeaderActionMenu, visualizeCapabilities } = services;
@ -380,4 +381,4 @@ const TopNav = ({
) : null;
};
export const VisualizeTopNav = memo(TopNav);
export const VisualizeTopNav = injectI18n(memo(TopNav));

View file

@ -410,6 +410,7 @@ export const getTopNavConfig = (
},
isDirty: hasUnappliedChanges || hasUnsavedChanges,
showPublicUrlSwitch,
toasts: toastNotifications,
});
}
},

View file

@ -1286,8 +1286,8 @@
"@kbn/repo-source-classifier-cli/*": ["packages/kbn-repo-source-classifier-cli/*"],
"@kbn/reporting-common": ["packages/kbn-reporting/common"],
"@kbn/reporting-common/*": ["packages/kbn-reporting/common/*"],
"@kbn/reporting-example-plugin": ["x-pack/examples/reporting_example"],
"@kbn/reporting-example-plugin/*": ["x-pack/examples/reporting_example/*"],
"@kbn/reporting-csv-share-panel": ["packages/kbn-reporting/get_csv_panel_actions"],
"@kbn/reporting-csv-share-panel/*": ["packages/kbn-reporting/get_csv_panel_actions/*"],
"@kbn/reporting-export-types-csv": ["packages/kbn-reporting/export_types/csv"],
"@kbn/reporting-export-types-csv/*": ["packages/kbn-reporting/export_types/csv/*"],
"@kbn/reporting-export-types-csv-common": ["packages/kbn-reporting/export_types/csv_common"],

View file

@ -1,33 +0,0 @@
# Example Reporting integration!
Use this example code to understand how to add a "Generate Report" button to a
Kibana page. This simple example shows that the end-to-end functionality of
generating a screenshot report of a page just requires you to render a React
component that you import from the Reportinng plugin.
A "reportable" Kibana page is one that has an **alternate version to show the data in a "screenshot-friendly" way**. The alternate version can be reached at a variation of the page's URL that the App team builds.
A "screenshot-friendly" page has **all interactive features turned off**. These are typically notifications, popups, tooltips, controls, autocomplete libraries, etc.
Turning off these features **keeps glitches out of the screenshot**, and makes the server-side headless browser **run faster and use less RAM**.
The URL that Reporting captures is controlled by the application, is a part of
a "jobParams" object that gets passed to the React component imported from
Reporting. The job params give the app control over the end-resulting report:
- Layout
- Page dimensions
- DOM attributes to select where the visualization container(s) is/are. The App team must add the attributes to DOM elements in their app.
- DOM events that the page fires off and signals when the rendering is done. The App team must implement triggering the DOM events around rendering the data in their app.
- Export type definition
- Processes the jobParams into output data, which is stored in Elasticsearch in the Reporting system index.
- Export type definitions are registered with the Reporting plugin at setup time.
The existing export type definitions are PDF, PNG, and CSV. They should be
enough for nearly any use case.
If the existing options are too limited for a future use case, the AppServices
team can assist the App team to implement a custom export type definition of
their own, and register it using the Reporting plugin API **(documentation coming soon)**.
---

View file

@ -1,14 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const PLUGIN_ID = 'reportingExample';
export const PLUGIN_NAME = 'reportingExample';
export type { MyForwardableState } from './types';
export type { ReportingExampleLocatorParams } from './locator';
export { REPORTING_EXAMPLE_LOCATOR_ID, ReportingExampleLocatorDefinition } from './locator';

View file

@ -1,32 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SerializableRecord } from '@kbn/utility-types';
import type { LocatorDefinition } from '@kbn/share-plugin/public';
import { PLUGIN_ID } from '.';
import type { MyForwardableState } from '../public/types';
export const REPORTING_EXAMPLE_LOCATOR_ID = 'REPORTING_EXAMPLE_LOCATOR_ID';
export type ReportingExampleLocatorParams = SerializableRecord;
export class ReportingExampleLocatorDefinition implements LocatorDefinition<{}> {
public readonly id = REPORTING_EXAMPLE_LOCATOR_ID;
migrations = {
'1.0.0': (state: {}) => ({ ...state, migrated: true }),
};
public readonly getLocation = async (params: MyForwardableState) => {
const path = Boolean(params.captureTest) ? '/captureTest' : '/';
return {
app: PLUGIN_ID,
path,
state: params,
};
};
}

View file

@ -1,13 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Ensure, SerializableRecord } from '@kbn/utility-types';
export type MyForwardableState = Ensure<
SerializableRecord & { captureTest: 'A' },
SerializableRecord
>;

View file

@ -1,19 +0,0 @@
{
"type": "plugin",
"id": "@kbn/reporting-example-plugin",
"owner": "@elastic/appex-sharedux",
"description": "Example integration code for applications to feature reports.",
"plugin": {
"id": "reportingExample",
"server": false,
"browser": true,
"requiredPlugins": [
"reporting",
"developerExamples",
"kibanaReact",
"navigation",
"screenshotMode",
"share"
]
}
}

View file

@ -1,40 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { Router, Routes, Route } from '@kbn/shared-ux-router';
import { AppMountParameters, CoreStart } from '@kbn/core/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { CaptureTest } from './containers/capture_test';
import { Main } from './containers/main';
import { ApplicationContextProvider } from './application_context';
import { SetupDeps, StartDeps, MyForwardableState } from './types';
import { ROUTES } from './constants';
export const renderApp = (
coreStart: CoreStart,
deps: Omit<StartDeps & SetupDeps, 'developerExamples'>,
{ appBasePath, element, history }: AppMountParameters, // FIXME: appBasePath is deprecated
forwardedParams: MyForwardableState
) => {
ReactDOM.render(
<ApplicationContextProvider forwardedState={forwardedParams}>
<KibanaThemeProvider theme$={coreStart.theme.theme$}>
<Router history={history}>
<Routes>
<Route path={ROUTES.captureTest} exact render={() => <CaptureTest />} />
<Route render={() => <Main basename={appBasePath} {...coreStart} {...deps} />} />
</Routes>
</Router>
</KibanaThemeProvider>
</ApplicationContextProvider>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -1,33 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useContext, createContext, FC } from 'react';
import type { MyForwardableState } from './types';
interface ContextValue {
forwardedState?: MyForwardableState;
}
const ApplicationContext = createContext<undefined | ContextValue>(undefined);
export const ApplicationContextProvider: FC<{ forwardedState: ContextValue['forwardedState'] }> = ({
forwardedState,
children,
}) => {
return (
<ApplicationContext.Provider value={{ forwardedState }}>{children}</ApplicationContext.Provider>
);
};
export const useApplicationContext = (): ContextValue => {
const ctx = useContext(ApplicationContext);
if (!ctx) {
throw new Error('useApplicationContext called outside of ApplicationContext!');
}
return ctx;
};

View file

@ -1,8 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { TestImageA } from './test_image_a';

File diff suppressed because one or more lines are too long

View file

@ -1,17 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// Values based on A4 page size
export const VIS = {
width: 1950 / 2,
height: 1200 / 2,
};
export const ROUTES = {
captureTest: '/captureTest',
main: '/',
};

View file

@ -1,10 +0,0 @@
.reportingExample {
&__captureContainer {
display: flex;
flex-direction: column;
align-items: center;
margin-top: $euiSizeM;
margin-bottom: $euiSizeM;
}
}

View file

@ -1,90 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { FunctionComponent } from 'react';
import React from 'react';
import { useHistory } from 'react-router-dom';
import { parsePath } from 'history';
import {
EuiTabbedContent,
EuiTabbedContentTab,
EuiSpacer,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiPage,
EuiPageHeader,
EuiPageBody,
EuiPageSection,
} from '@elastic/eui';
import { TestImageA } from '../components';
import { useApplicationContext } from '../application_context';
import { MyForwardableState } from '../types';
import { ROUTES } from '../constants';
import './capture_test.scss';
const ItemsContainer: FunctionComponent<{ count: string }> = ({ count, children }) => (
<div
className="reportingExample__captureContainer"
data-shared-items-container
data-shared-items-count={count}
>
{children}
</div>
);
const tabs: Array<EuiTabbedContentTab & { id: MyForwardableState['captureTest'] }> = [
{
id: 'A',
name: 'Test A',
content: (
<ItemsContainer count="4">
<TestImageA />
<TestImageA />
<TestImageA />
<TestImageA />
</ItemsContainer>
),
},
];
export const CaptureTest: FunctionComponent = () => {
const { forwardedState } = useApplicationContext();
const tabToRender = forwardedState?.captureTest;
const history = useHistory();
return (
<EuiPage>
<EuiPageBody>
<EuiPageSection>
<EuiPageHeader>
<EuiFlexGroup alignItems="center" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="arrowLeft"
href={history.createHref(parsePath(ROUTES.main))}
>
Back to main
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageHeader>
</EuiPageSection>
<EuiPageSection>
<EuiSpacer />
<EuiTabbedContent
tabs={tabs}
initialSelectedTab={
tabToRender ? tabs.find((tab) => tab.id === tabToRender) : undefined
}
/>
</EuiPageSection>
</EuiPageBody>
</EuiPage>
);
};

View file

@ -1,378 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { parsePath } from 'history';
import moment from 'moment';
import React, { useEffect, useState } from 'react';
import * as Rx from 'rxjs';
import { takeWhile } from 'rxjs';
import {
EuiButton,
EuiCard,
EuiCodeBlock,
EuiContextMenu,
EuiContextMenuProps,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiIcon,
EuiLink,
EuiPage,
EuiPageBody,
EuiPageHeader,
EuiPageSection,
EuiPopover,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { I18nProvider } from '@kbn/i18n-react';
import { JobParamsPDFDeprecated, JobParamsPDFV2 } from '@kbn/reporting-export-types-pdf-common';
import { JobParamsPNGV2 } from '@kbn/reporting-export-types-png-common';
import type { ReportingStart } from '@kbn/reporting-plugin/public';
import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/public';
import { BrowserRouter as Router, useHistory } from 'react-router-dom';
import { REPORTING_EXAMPLE_LOCATOR_ID } from '../../common';
import { useApplicationContext } from '../application_context';
import { ROUTES } from '../constants';
import type { MyForwardableState } from '../types';
interface ReportingExampleAppProps {
basename: string;
reporting: ReportingStart;
screenshotMode: ScreenshotModePluginSetup;
}
const sourceLogos = ['Beats', 'Cloud', 'Logging', 'Kibana'];
export const Main = ({ basename, reporting, screenshotMode }: ReportingExampleAppProps) => {
const history = useHistory();
const { forwardedState } = useApplicationContext();
useEffect(() => {
// eslint-disable-next-line no-console
console.log('forwardedState', forwardedState);
}, [forwardedState]);
// Context Menu
const [isPopoverOpen, setPopover] = useState(false);
const onButtonClick = () => {
setPopover(!isPopoverOpen);
};
const closePopover = () => {
setPopover(false);
};
// Async Logos
const [logos, setLogos] = useState<string[]>([]);
useEffect(() => {
Rx.timer(2200)
.pipe(takeWhile(() => logos.length < sourceLogos.length))
.subscribe(() => {
setLogos([...sourceLogos.slice(0, logos.length + 1)]);
});
});
const getPDFJobParamsDefault = (): JobParamsPDFDeprecated => {
return {
layout: { id: 'preserve_layout' },
relativeUrls: ['/app/reportingExample#/intended-visualization'],
objectType: 'develeloperExample',
title: 'Reporting Developer Example',
browserTimezone: 'UTC',
version: '1',
};
};
const getPDFJobParamsDefaultV2 = (): JobParamsPDFV2 => {
return {
version: '8.0.0',
layout: { id: 'preserve_layout' },
locatorParams: [
{ id: REPORTING_EXAMPLE_LOCATOR_ID, version: '0.5.0', params: { myTestState: {} } },
],
objectType: 'develeloperExample',
title: 'Reporting Developer Example',
browserTimezone: moment.tz.guess(),
};
};
const getPNGJobParamsDefaultV2 = (): JobParamsPNGV2 => {
return {
version: '8.0.0',
layout: { id: 'preserve_layout' },
locatorParams: {
id: REPORTING_EXAMPLE_LOCATOR_ID,
version: '0.5.0',
params: { myTestState: {} },
},
objectType: 'develeloperExample',
title: 'Reporting Developer Example',
browserTimezone: moment.tz.guess(),
};
};
const getCaptureTestPNGJobParams = (): JobParamsPNGV2 => {
return {
version: '8.0.0',
layout: { id: 'preserve_layout' },
locatorParams: {
id: REPORTING_EXAMPLE_LOCATOR_ID,
version: '0.5.0',
params: { captureTest: 'A' } as MyForwardableState,
},
objectType: 'develeloperExample',
title: 'Reporting Developer Example',
browserTimezone: moment.tz.guess(),
};
};
const getCaptureTestPDFJobParams = (print: boolean) => (): JobParamsPDFV2 => {
return {
version: '8.0.0',
layout: {
id: print ? 'print' : 'preserve_layout',
dimensions: {
// Magic numbers based on height of components not rendered on this screen :(
height: 2400,
width: 1822,
},
},
locatorParams: [
{
id: REPORTING_EXAMPLE_LOCATOR_ID,
version: '0.5.0',
params: { captureTest: 'A' } as MyForwardableState,
},
],
objectType: 'develeloperExample',
title: 'Reporting Developer Example',
browserTimezone: moment.tz.guess(),
};
};
const panels: EuiContextMenuProps['panels'] = [
{
id: 0,
items: [
{ name: 'PDF Reports', icon: 'document', panel: 1 },
{ name: 'PNG Reports', icon: 'document', panel: 7 },
{ name: 'Capture test', icon: 'document', panel: 8, 'data-test-subj': 'captureTestPanel' },
],
},
{
id: 1,
initialFocusedItemIndex: 1,
title: 'PDF Reports',
items: [
{ name: 'Default layout', icon: 'document', panel: 2 },
{ name: 'Default layout V2', icon: 'document', panel: 4 },
{ name: 'Canvas Layout Option', icon: 'canvasApp', panel: 3 },
],
},
{
id: 8,
initialFocusedItemIndex: 0,
title: 'Capture test',
items: [
{
name: 'Capture test A - PNG',
icon: 'document',
panel: 9,
'data-test-subj': 'captureTestPNG',
},
{
name: 'Capture test A - PDF',
icon: 'document',
panel: 10,
'data-test-subj': 'captureTestPDF',
},
{
name: 'Capture test A - PDF print optimized',
icon: 'document',
panel: 11,
'data-test-subj': 'captureTestPDFPrint',
},
],
},
{
id: 7,
initialFocusedItemIndex: 0,
title: 'PNG Reports',
items: [{ name: 'Default layout V2', icon: 'document', panel: 5 }],
},
{
id: 2,
title: 'Default layout',
content: (
<reporting.components.ReportingPanelPDF
getJobParams={getPDFJobParamsDefault}
onClose={closePopover}
/>
),
},
{
id: 3,
title: 'Canvas Layout Option',
content: (
<reporting.components.ReportingPanelPDF
layoutOption="canvas"
getJobParams={getPDFJobParamsDefault}
onClose={closePopover}
/>
),
},
{
id: 4,
title: 'Default layout V2',
content: (
<reporting.components.ReportingPanelPDFV2
getJobParams={getPDFJobParamsDefaultV2}
onClose={closePopover}
/>
),
},
{
id: 5,
title: 'Default layout V2',
content: (
<reporting.components.ReportingPanelPNGV2
getJobParams={getPNGJobParamsDefaultV2}
onClose={closePopover}
/>
),
},
{
id: 9,
title: 'Test A',
content: (
<reporting.components.ReportingPanelPNGV2
getJobParams={getCaptureTestPNGJobParams}
onClose={closePopover}
/>
),
},
{
id: 10,
title: 'Test A',
content: (
<reporting.components.ReportingPanelPDFV2
getJobParams={getCaptureTestPDFJobParams(false)}
onClose={closePopover}
/>
),
},
{
id: 11,
title: 'Test A',
content: (
<reporting.components.ReportingPanelPDFV2
layoutOption="print"
getJobParams={getCaptureTestPDFJobParams(true)}
onClose={closePopover}
/>
),
},
];
return (
<Router basename={basename}>
<I18nProvider>
<EuiPage>
<EuiPageBody>
<EuiPageHeader>
<EuiTitle size="l">
<h1>Reporting Example</h1>
</EuiTitle>
</EuiPageHeader>
<EuiPageSection>
<EuiTitle>
<h2>Example of a Sharing menu using components from Reporting</h2>
</EuiTitle>
<EuiSpacer />
<EuiText>
<EuiFlexGroup alignItems="center" gutterSize="l">
<EuiFlexItem grow={false}>
<EuiPopover
id="contextMenuExample"
button={
<EuiButton data-test-subj="shareButton" onClick={onButtonClick}>
Share
</EuiButton>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenu initialPanelId={0} panels={panels} />
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s">
<EuiLink href={history.createHref(parsePath(ROUTES.captureTest))}>
Go to capture test
</EuiLink>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule />
<div data-shared-items-container data-shared-items-count="5">
<EuiFlexGroup gutterSize="l">
<EuiFlexItem data-shared-item>
{forwardedState ? (
<>
<EuiText>
<p>
<strong>Forwarded app state</strong>
</p>
</EuiText>
<EuiCodeBlock>{JSON.stringify(forwardedState)}</EuiCodeBlock>
</>
) : (
<>
<EuiText>
<p>
<strong>No forwarded app state found</strong>
</p>
</EuiText>
<EuiCodeBlock>{'{}'}</EuiCodeBlock>
</>
)}
</EuiFlexItem>
{logos.map((item, index) => (
<EuiFlexItem
key={index}
data-shared-item
data-shared-render-error
data-render-error="This is an example error"
>
<EuiCard
icon={<EuiIcon size="xxl" type={`logo${item}`} />}
title={`Elastic ${item}`}
description="Example of a card's description. Stick to one or two sentences."
onClick={() => {}}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
<p>Screenshot Mode is {screenshotMode.isScreenshotMode() ? 'ON' : 'OFF'}!</p>
</div>
</EuiText>
</EuiPageSection>
</EuiPageBody>
</EuiPage>
</I18nProvider>
</Router>
);
};

View file

@ -1,13 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ReportingExamplePlugin } from './plugin';
export function plugin() {
return new ReportingExamplePlugin();
}
export type { PluginSetup, PluginStart } from './types';

View file

@ -1,49 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { PLUGIN_ID, PLUGIN_NAME, ReportingExampleLocatorDefinition } from '../common';
import { SetupDeps, StartDeps, MyForwardableState } from './types';
export class ReportingExamplePlugin implements Plugin<void, void, {}, {}> {
public setup(core: CoreSetup, { developerExamples, screenshotMode, share }: SetupDeps): void {
core.application.register({
id: PLUGIN_ID,
title: PLUGIN_NAME,
visibleIn: [],
async mount(params: AppMountParameters) {
// Load application bundle
const { renderApp } = await import('./application');
const [coreStart, depsStart] = (await core.getStartServices()) as [
CoreStart,
StartDeps,
unknown
];
// Render the application
return renderApp(
coreStart,
{ ...depsStart, screenshotMode, share },
params,
params.history.location.state as MyForwardableState
);
},
});
// Show the app in Developer Examples
developerExamples.register({
appId: 'reportingExample',
title: 'Reporting integration',
description: 'Demonstrate how to put an Export button on a page and generate reports.',
});
share.url.locators.create(new ReportingExampleLocatorDefinition());
}
public start() {}
public stop() {}
}

View file

@ -1,30 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/public';
import { SharePluginSetup } from '@kbn/share-plugin/public';
import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
import { ReportingStart } from '@kbn/reporting-plugin/public';
import type { MyForwardableState } from '../common';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PluginSetup {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PluginStart {}
export interface SetupDeps {
developerExamples: DeveloperExamplesSetup;
share: SharePluginSetup;
screenshotMode: ScreenshotModePluginSetup;
}
export interface StartDeps {
navigation: NavigationPublicPluginStart;
reporting: ReportingStart;
}
export type { MyForwardableState };

View file

@ -1,31 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"index.ts",
"public/**/*.ts",
"public/**/*.tsx",
"server/**/*.ts",
"common/**/*.ts",
"../../../typings/**/*"
],
"exclude": [
"target/**/*",
],
"kbn_references": [
"@kbn/core",
"@kbn/kibana-react-plugin",
"@kbn/navigation-plugin",
"@kbn/screenshot-mode-plugin",
"@kbn/developer-examples-plugin",
"@kbn/reporting-plugin",
"@kbn/share-plugin",
"@kbn/i18n-react",
"@kbn/utility-types",
"@kbn/shared-ux-router",
"@kbn/reporting-export-types-pdf-common",
"@kbn/reporting-export-types-png-common",
]
}

View file

@ -35,7 +35,7 @@
"expressionTagcloud",
"eventAnnotation",
"unifiedSearch",
"contentManagement"
"contentManagement",
],
"optionalPlugins": [
"expressionLegacyMetricVis",
@ -45,7 +45,8 @@
"globalSearch",
"savedObjectsTagging",
"spaces",
"serverless"
"serverless",
"licensing"
],
"requiredBundles": [
"unifiedSearch",

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiButton, EuiForm, EuiSpacer, EuiText } from '@elastic/eui';
import { EuiButton, EuiForm, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
@ -21,32 +21,33 @@ export function DownloadPanelContent({
warnings = [],
}: DownloadPanelContentProps) {
return (
<EuiForm className="kbnShareContextMenu__finalPanel" data-test-subj="shareReportingForm">
<EuiText size="s">
<p>
<>
<EuiForm className="kbnShareContextMenu__finalPanel" data-test-subj="shareReportingForm">
<EuiText size="s">
<FormattedMessage
id="xpack.lens.application.csvPanelContent.generationDescription"
defaultMessage="Download the data displayed in the visualization."
/>
</p>
{warnings.map((warning, i) => (
<p key={i}>{warning}</p>
))}
</EuiText>
<EuiSpacer size="s" />
<EuiButton
disabled={isDisabled}
fullWidth
fill
onClick={onClick}
data-test-subj="lnsApp_downloadCSVButton"
size="s"
>
<FormattedMessage
id="xpack.lens.application.csvPanelContent.downloadButtonLabel"
defaultMessage="Export as CSV"
/>
</EuiButton>
</EuiForm>
{warnings.map((warning, i) => (
<p key={i}>{warning}</p>
))}
</EuiText>
<EuiSpacer size="m" />
<EuiFormRow fullWidth={true}>
<EuiButton
disabled={isDisabled}
onClick={onClick}
data-test-subj="lnsApp_downloadCSVButton"
fill
fullWidth
>
<FormattedMessage
id="xpack.lens.application.csvPanelContent.downloadButtonLabel"
defaultMessage="Generate CSV"
/>
</EuiButton>
</EuiFormRow>
</EuiForm>
</>
);
}

View file

@ -11,9 +11,10 @@ import { tableHasFormulas } from '@kbn/data-plugin/common';
import { downloadMultipleAs, ShareContext, ShareMenuProvider } from '@kbn/share-plugin/public';
import { exporters } from '@kbn/data-plugin/public';
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { FormattedMessage } from '@kbn/i18n-react';
import { FormatFactory } from '../../../common/types';
import { DownloadPanelContent } from './csv_download_panel_content_lazy';
import { TableInspectorAdapter } from '../../editor_frame_service/types';
import { DownloadPanelContent } from './csv_download_panel_content_lazy';
declare global {
interface Window {
@ -96,13 +97,17 @@ function getWarnings(activeData: TableInspectorAdapter) {
interface DownloadPanelShareOpts {
uiSettings: IUiSettingsClient;
formatFactoryFn: () => FormatFactory;
atLeastGold: () => boolean;
isNewVersion: boolean;
}
export const downloadCsvShareProvider = ({
uiSettings,
formatFactoryFn,
atLeastGold,
isNewVersion,
}: DownloadPanelShareOpts): ShareMenuProvider => {
const getShareMenuItems = ({ objectType, sharingData, onClose }: ShareContext) => {
const getShareMenuItems = ({ objectType, sharingData }: ShareContext) => {
if ('lens' !== objectType) {
return [];
}
@ -121,40 +126,87 @@ export const downloadCsvShareProvider = ({
}
);
const menuItemMetadata = {
shareMenuItem: {
name: panelTitle,
icon: 'document',
disabled: !csvEnabled,
sortOrder: 1,
},
};
const downloadCSVHandler = () =>
downloadCSVs({
title,
formatFactory: formatFactoryFn(),
activeData,
uiSettings,
columnsSorting,
});
if (!isNewVersion) {
return [
{
...menuItemMetadata,
panel: {
id: 'csvDownloadPanel',
title: panelTitle,
content: (
<DownloadPanelContent
isDisabled={!csvEnabled}
warnings={getWarnings(activeData)}
onClick={downloadCSVHandler}
/>
),
},
},
];
}
return [
{
shareMenuItem: {
name: panelTitle,
icon: 'document',
disabled: !csvEnabled,
sortOrder: 1,
},
panel: {
id: 'csvDownloadPanel',
title: panelTitle,
content: (
<DownloadPanelContent
isDisabled={!csvEnabled}
warnings={getWarnings(activeData)}
onClick={async () => {
await downloadCSVs({
title,
formatFactory: formatFactoryFn(),
activeData,
uiSettings,
columnsSorting,
});
onClose?.();
}}
/>
),
},
...menuItemMetadata,
label: 'CSV' as const,
reportType: 'lens_csv',
downloadCSVLens: downloadCSVHandler,
...(atLeastGold()
? {
helpText: (
<FormattedMessage
id="xpack.lens.share.helpText"
defaultMessage="Select the file type you would like to export for this visualization."
/>
),
generateReportButton: (
<FormattedMessage id="xpack.lens.share.export" defaultMessage="Generate export" />
),
renderLayoutOptionSwitch: false,
getJobParams: undefined,
showRadios: true,
}
: {
isDisabled: !csvEnabled,
warnings: getWarnings(activeData),
helpText: (
<FormattedMessage
id="xpack.lens.application.csvPanelContent.generationDescription"
defaultMessage="Download the data displayed in the visualization."
/>
),
generateReportButton: (
<FormattedMessage
id="xpack.lens.share.csvButton"
data-test-subj="generateReportButton"
defaultMessage="Download CSV"
/>
),
}),
},
];
};
return {
id: 'csvDownload',
id: 'csvDownloadLens',
getShareMenuItems,
};
};

View file

@ -297,6 +297,7 @@ export const LensTopNavMenu = ({
dataViewFieldEditor,
dataViewEditor,
dataViews: dataViewsService,
notifications,
} = useKibana<LensAppServices>().services;
const {
@ -639,6 +640,7 @@ export const LensTopNavMenu = ({
onClose: () => {
anchorElement?.focus();
},
toasts: notifications.toasts,
});
},
},
@ -793,6 +795,7 @@ export const LensTopNavMenu = ({
isOnTextBasedMode,
lensStore,
theme$,
notifications.toasts,
]);
const onQuerySubmitWrapped = useCallback(

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { take } from 'rxjs';
import type { AppMountParameters, CoreSetup, CoreStart } from '@kbn/core/public';
import type { Start as InspectorStartContract } from '@kbn/inspector-plugin/public';
import type { FieldFormatsSetup, FieldFormatsStart } from '@kbn/field-formats-plugin/public';
@ -63,6 +64,7 @@ import {
import { i18n } from '@kbn/i18n';
import type { ServerlessPluginStart } from '@kbn/serverless/public';
import { registerSavedObjectToPanelMethod } from '@kbn/embeddable-plugin/public';
import { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import type { EditorFrameService as EditorFrameServiceType } from './editor_frame_service';
import type {
FormBasedDatasource as FormBasedDatasourceType,
@ -179,6 +181,7 @@ export interface LensPluginStartDependencies {
eventAnnotationService: EventAnnotationServiceType;
contentManagement: ContentManagementPublicStart;
serverless?: ServerlessPluginStart;
licensing?: LicensingPluginStart;
}
export interface LensPublicSetup {
@ -393,6 +396,17 @@ export class LensPlugin {
downloadCsvShareProvider({
uiSettings: core.uiSettings,
formatFactoryFn: () => startServices().plugins.fieldFormats.deserialize,
atLeastGold: () => {
let isGold = false;
startServices()
.plugins.licensing?.license$.pipe(take(1))
.subscribe((license) => {
// need to make sure user has correct license and permissions to see PDF/PNG
isGold = license.hasAtLeast('gold');
});
return isGold;
},
isNewVersion: share.isNewVersion(),
})
);
}

View file

@ -111,7 +111,8 @@
"@kbn/presentation-publishing",
"@kbn/saved-objects-finder-plugin",
"@kbn/unified-data-table",
"@kbn/shared-ux-markdown"
"@kbn/shared-ux-markdown",
"@kbn/licensing-plugin",
],
"exclude": ["target/**/*"]
}

View file

@ -22,13 +22,13 @@
"taskManager",
"screenshotMode",
"share",
"features"
"features",
],
"optionalPlugins": [
"security",
"spaces",
"usageCollection",
"screenshotting"
"screenshotting",
],
"requiredBundles": [
"embeddable",

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import * as Rx from 'rxjs';
import { from, ReplaySubject } from 'rxjs';
import {
CoreSetup,
@ -30,11 +30,13 @@ import type { ClientConfigType } from '@kbn/reporting-public';
import { ReportingAPIClient } from '@kbn/reporting-public';
import {
ReportingCsvPanelAction,
getSharedComponents,
reportingCsvShareProvider,
reportingCsvShareModalProvider,
reportingExportModalProvider,
reportingScreenshotShareProvider,
} from '@kbn/reporting-public/share';
import { ReportingCsvPanelAction } from '@kbn/reporting-csv-share-panel';
import type { ReportingSetup, ReportingStart } from '.';
import { ReportingNotifierStreamHandler as StreamHandler } from './lib/stream_handler';
@ -70,7 +72,7 @@ export class ReportingPublicPlugin
{
private kibanaVersion: string;
private apiClient?: ReportingAPIClient;
private readonly stop$ = new Rx.ReplaySubject<void>(1);
private readonly stop$ = new ReplaySubject<void>(1);
private readonly title = i18n.translate('xpack.reporting.management.reportingTitle', {
defaultMessage: 'Reporting',
});
@ -123,7 +125,7 @@ export class ReportingPublicPlugin
uiActions: uiActionsSetup,
} = setupDeps;
const startServices$ = Rx.from(getStartServices());
const startServices$ = from(getStartServices());
const usesUiCapabilities = !this.config.roles.enabled;
const apiClient = this.getApiClient(core.http, core.uiSettings);
@ -205,7 +207,7 @@ export class ReportingPublicPlugin
const reportingStart = this.getContract(core);
const { toasts } = core.notifications;
startServices$.subscribe(([{ application }, { licensing }]) => {
startServices$.subscribe(([{ application, i18n: i18nStart }, { licensing }]) => {
licensing.license$.subscribe((license) => {
shareSetup.register(
reportingCsvShareProvider({
@ -218,8 +220,8 @@ export class ReportingPublicPlugin
theme: core.theme,
})
);
if (this.config.export_types.pdf.enabled || this.config.export_types.png.enabled) {
// needed for Canvas and legacy tests
shareSetup.register(
reportingScreenshotShareProvider({
apiClient,
@ -232,9 +234,35 @@ export class ReportingPublicPlugin
})
);
}
if (shareSetup.isNewVersion()) {
shareSetup.register(
reportingCsvShareModalProvider({
apiClient,
uiSettings,
license,
application,
usesUiCapabilities,
theme: core.theme,
i18n: i18nStart,
})
);
if (this.config.export_types.pdf.enabled || this.config.export_types.png.enabled) {
shareSetup.register(
reportingExportModalProvider({
apiClient,
uiSettings,
license,
application,
usesUiCapabilities,
theme: core.theme,
i18n: i18nStart,
})
);
}
}
});
});
return reportingStart;
}

View file

@ -49,6 +49,7 @@
"@kbn/core-http-request-handler-context-server",
"@kbn/reporting-public",
"@kbn/analytics-client",
"@kbn/reporting-csv-share-panel",
],
"exclude": [
"target/**/*",

View file

@ -26,7 +26,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
testFiles: [
require.resolve('./search_examples'),
require.resolve('./embedded_lens'),
require.resolve('./reporting_examples'),
require.resolve('./screenshotting'),
require.resolve('./triggers_actions_ui_examples'),
],

View file

@ -1,75 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import path from 'path';
import type { FtrProviderContext } from '../../functional/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function ({
getService,
getPageObjects,
updateBaselines,
}: FtrProviderContext & { updateBaselines: boolean }) {
const PageObjects = getPageObjects(['common', 'reporting']);
const browser = getService('browser');
const testSubjects = getService('testSubjects');
const png = getService('png');
const config = getService('config');
const log = getService('log');
const screenshotDir = config.get('screenshots.directory');
const appId = 'reportingExample';
const fixtures = {
baselineAPng: path.resolve(__dirname, 'fixtures/baseline/capture_a.png'),
baselineAPdf: path.resolve(__dirname, 'fixtures/baseline/capture_a.pdf'),
baselineAPdfPrint: path.resolve(__dirname, 'fixtures/baseline/capture_a_print.pdf'),
};
// NOTE: Occasionally, you may need to run the test and copy the "session" image file and replace the
// "baseline" image file to reflect current renderings. The source and destination file paths can be found in
// the INFO logs for this test run.
describe('Captures', () => {
before(async () => {
await browser.setWindowSize(1600, 1000);
});
it('PNG file matches the baseline image', async () => {
await PageObjects.common.navigateToApp(appId);
await (await testSubjects.find('shareButton')).click();
await (await testSubjects.find('captureTestPanel')).click();
await (await testSubjects.find('captureTestPNG')).click();
await PageObjects.reporting.clickGenerateReportButton();
const url = await PageObjects.reporting.getReportURL(60000);
const captureData = await PageObjects.reporting.getRawPdfReportData(url);
const pngSessionFilePath = await PageObjects.reporting.writeSessionReport(
'capture_test_baseline_a',
'png',
captureData,
screenshotDir
);
log.info(
`session image path: ${pngSessionFilePath}` +
`, baseline image path: ${fixtures.baselineAPng}`
);
expect(
await png.compareAgainstBaseline(
pngSessionFilePath,
fixtures.baselineAPng,
screenshotDir,
updateBaselines
)
).to.be.lessThan(0.03);
});
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 589 KiB

View file

@ -1,15 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { PluginFunctionalProviderContext } from '../../../../test/plugin_functional/services';
// eslint-disable-next-line import/no-default-export
export default function ({ loadTestFile }: PluginFunctionalProviderContext) {
describe('reporting examples', function () {
loadTestFile(require.resolve('./capture_test'));
});
}

View file

@ -5624,7 +5624,7 @@
version "0.0.0"
uid ""
"@kbn/reporting-example-plugin@link:x-pack/examples/reporting_example":
"@kbn/reporting-csv-share-panel@link:packages/kbn-reporting/get_csv_panel_actions":
version "0.0.0"
uid ""