diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ecd3990f1263d4..1fe4a09ca0412f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -644,6 +644,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 +packages/kbn-reporting/get_csv_panel_actions @elastic/appex-sharedux x-pack/examples/reporting_example @elastic/appex-sharedux packages/kbn-reporting/export_types/csv @elastic/appex-sharedux packages/kbn-reporting/export_types/csv_common @elastic/appex-sharedux @@ -794,6 +795,7 @@ packages/shared-ux/router/mocks @elastic/appex-sharedux packages/shared-ux/router/types @elastic/appex-sharedux packages/shared-ux/storybook/config @elastic/appex-sharedux packages/shared-ux/storybook/mock @elastic/appex-sharedux +packages/shared-ux/modal/tabbed @elastic/appex-sharedux packages/kbn-shared-ux-utility @elastic/appex-sharedux x-pack/plugins/observability_solution/slo @elastic/obs-ux-management-team x-pack/packages/kbn-slo-schema @elastic/obs-ux-management-team diff --git a/package.json b/package.json index 1fb67303fb553a..e2eb935db9ac7f 100644 --- a/package.json +++ b/package.json @@ -648,6 +648,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-csv-share-panel": "link:packages/kbn-reporting/get_csv_panel_actions", "@kbn/reporting-example-plugin": "link:x-pack/examples/reporting_example", "@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", @@ -796,6 +797,7 @@ "@kbn/shared-ux-router-types": "link:packages/shared-ux/router/types", "@kbn/shared-ux-storybook-config": "link:packages/shared-ux/storybook/config", "@kbn/shared-ux-storybook-mock": "link:packages/shared-ux/storybook/mock", + "@kbn/shared-ux-tabbed-modal": "link:packages/shared-ux/modal/tabbed", "@kbn/shared-ux-utility": "link:packages/kbn-shared-ux-utility", "@kbn/slo-plugin": "link:x-pack/plugins/observability_solution/slo", "@kbn/slo-schema": "link:x-pack/packages/kbn-slo-schema", diff --git a/packages/kbn-generate-csv/src/generate_csv.ts b/packages/kbn-generate-csv/src/generate_csv.ts index c59e4fd40aafb9..84840c9ae9f91a 100644 --- a/packages/kbn-generate-csv/src/generate_csv.ts +++ b/packages/kbn-generate-csv/src/generate_csv.ts @@ -223,6 +223,7 @@ export class CsvGenerator { public async generateData(): Promise { const logger = this.logger; + const [settings, searchSource] = await Promise.all([ getExportSettings( this.clients.uiSettings, diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index d8cd1983662ffb..97308b8d384802 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -84,7 +84,7 @@ pageLoadAssetSize: kibanaUsageCollection: 16463 kibanaUtils: 79713 kubernetesSecurity: 77234 - lens: 57135 + lens: 96692 licenseManagement: 41817 licensing: 29004 links: 44490 @@ -115,7 +115,7 @@ pageLoadAssetSize: presentationUtil: 58834 profiling: 36694 remoteClusters: 51327 - reporting: 57003 + reporting: 78653 rollup: 97204 runtimeFields: 41752 savedObjects: 108518 diff --git a/packages/kbn-reporting/get_csv_panel_actions/index.ts b/packages/kbn-reporting/get_csv_panel_actions/index.ts new file mode 100644 index 00000000000000..c15c604dc859eb --- /dev/null +++ b/packages/kbn-reporting/get_csv_panel_actions/index.ts @@ -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'; diff --git a/packages/kbn-reporting/get_csv_panel_actions/jest.config.js b/packages/kbn-reporting/get_csv_panel_actions/jest.config.js new file mode 100644 index 00000000000000..3322e94a6aa4dc --- /dev/null +++ b/packages/kbn-reporting/get_csv_panel_actions/jest.config.js @@ -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: ['/packages/kbn-reporting/get_csv_panel_actions'], +}; diff --git a/packages/kbn-reporting/get_csv_panel_actions/kibana.jsonc b/packages/kbn-reporting/get_csv_panel_actions/kibana.jsonc new file mode 100644 index 00000000000000..a37c3dbc2d61bb --- /dev/null +++ b/packages/kbn-reporting/get_csv_panel_actions/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-browser", + "id": "@kbn/reporting-csv-share-panel", + "owner": "@elastic/appex-sharedux" +} diff --git a/packages/kbn-reporting/get_csv_panel_actions/package.json b/packages/kbn-reporting/get_csv_panel_actions/package.json new file mode 100644 index 00000000000000..3842e94ea75517 --- /dev/null +++ b/packages/kbn-reporting/get_csv_panel_actions/package.json @@ -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" +} \ No newline at end of file diff --git a/packages/kbn-reporting/public/share/panel_actions/get_csv_panel_action.test.ts b/packages/kbn-reporting/get_csv_panel_actions/panel_actions/get_csv_panel_action.test.ts similarity index 98% rename from packages/kbn-reporting/public/share/panel_actions/get_csv_panel_action.test.ts rename to packages/kbn-reporting/get_csv_panel_actions/panel_actions/get_csv_panel_action.test.ts index c009bb4be965d7..b057d81cfc877f 100644 --- a/packages/kbn-reporting/public/share/panel_actions/get_csv_panel_action.test.ts +++ b/packages/kbn-reporting/get_csv_panel_actions/panel_actions/get_csv_panel_action.test.ts @@ -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, diff --git a/packages/kbn-reporting/public/share/panel_actions/get_csv_panel_action.tsx b/packages/kbn-reporting/get_csv_panel_actions/panel_actions/get_csv_panel_action.tsx similarity index 97% rename from packages/kbn-reporting/public/share/panel_actions/get_csv_panel_action.tsx rename to packages/kbn-reporting/get_csv_panel_actions/panel_actions/get_csv_panel_action.tsx index 96ed679a1e18e9..cd34d64a8429aa 100644 --- a/packages/kbn-reporting/public/share/panel_actions/get_csv_panel_action.tsx +++ b/packages/kbn-reporting/get_csv_panel_actions/panel_actions/get_csv_panel_action.tsx @@ -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( diff --git a/packages/kbn-reporting/public/share/panel_actions/strings.tsx b/packages/kbn-reporting/get_csv_panel_actions/panel_actions/strings.tsx similarity index 97% rename from packages/kbn-reporting/public/share/panel_actions/strings.tsx rename to packages/kbn-reporting/get_csv_panel_actions/panel_actions/strings.tsx index 729d0265d42067..d06ff717dbe1ff 100644 --- a/packages/kbn-reporting/public/share/panel_actions/strings.tsx +++ b/packages/kbn-reporting/get_csv_panel_actions/panel_actions/strings.tsx @@ -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; diff --git a/packages/kbn-reporting/get_csv_panel_actions/tsconfig.json b/packages/kbn-reporting/get_csv_panel_actions/tsconfig.json new file mode 100644 index 00000000000000..39839a3e9768d7 --- /dev/null +++ b/packages/kbn-reporting/get_csv_panel_actions/tsconfig.json @@ -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", + ] +} diff --git a/packages/kbn-reporting/public/share/index.ts b/packages/kbn-reporting/public/share/index.ts index 2a1c9adc2a7f8b..d55575b1102cce 100644 --- a/packages/kbn-reporting/public/share/index.ts +++ b/packages/kbn-reporting/public/share/index.ts @@ -7,7 +7,10 @@ */ export { getSharedComponents } from './shared'; -export { reportingScreenshotShareProvider } from './share_context_menu/register_pdf_png_reporting'; +export { + reportingScreenshotShareProvider, + reportingExportModalProvider, +} 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 type { ReportingPublicComponents } from './shared/get_shared_components'; +export type { JobParamsProviderOptions } from './share_context_menu'; diff --git a/packages/kbn-reporting/public/share/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap b/packages/kbn-reporting/public/share/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap index 16b666d519c467..5b8144201517e6 100644 --- a/packages/kbn-reporting/public/share/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap +++ b/packages/kbn-reporting/public/share/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap @@ -196,338 +196,3 @@ exports[`ScreenCapturePanelContent properly renders a view with "canvas" layout `; - -exports[`ScreenCapturePanelContent properly renders a view with "print" layout option 1`] = ` -
-
-

- - Analytical Apps can take a minute or two to generate based upon the size of your test-object-type. - -

-
-
-
-
-
- - - - Optimize for printing - - -
-
- - Uses multiple pages, showing at most 2 visualizations per page - -
-
-
- -
-
-
- - -
-
-
-
-
-

- - Alternatively, copy this POST URL to call generation from outside Kibana or from Watcher. - -

-
-
-
-

-

-
-
-
-

- - Save your work before copying this URL. - -

-
-
-
-
-
-
-
-
-`; - -exports[`ScreenCapturePanelContent renders the default view properly 1`] = ` -
-
-

- - Analytical Apps can take a minute or two to generate based upon the size of your test-object-type. - -

-
-
- -
-
-
- - -
-
-
-
-
-

- - Alternatively, copy this POST URL to call generation from outside Kibana or from Watcher. - -

-
-
-
-

-

-
-
-
-

- - Save your work before copying this URL. - -

-
-
-
-
-
-
-
-
-`; diff --git a/packages/kbn-reporting/public/share/share_context_menu/csv_export_modal.tsx b/packages/kbn-reporting/public/share/share_context_menu/csv_export_modal.tsx new file mode 100644 index 00000000000000..fbe8dfad1fa4a1 --- /dev/null +++ b/packages/kbn-reporting/public/share/share_context_menu/csv_export_modal.tsx @@ -0,0 +1,256 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiCopy, + EuiModalBody, + EuiModalFooter, + EuiSpacer, + EuiToolTip, + EuiIcon, +} from '@elastic/eui'; +import type { IUiSettingsClient, ThemeServiceSetup, ToastsSetup } from '@kbn/core/public'; +import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n-react'; +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import React, { FC, useEffect, useMemo, useState } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; +import url from 'url'; + +import { i18n } from '@kbn/i18n'; +import { BaseParams } from '@kbn/reporting-common/types'; +import { ErrorUrlTooLongPanel } from './reporting_panel_content/components'; +import { getMaxUrlLength } from './reporting_panel_content/constants'; +import { ReportingAPIClient } from '../..'; + +export interface CsvModalProps { + apiClient: ReportingAPIClient; + toasts: ToastsSetup; + uiSettings: IUiSettingsClient; + reportType: string; + requiresSavedState: boolean; // Whether the report to be generated requires saved state that is not captured in the URL submitted to the report generator. + getJobParams: (forShareUrl?: boolean) => Omit; + objectId?: string; + isDirty?: boolean; + onClose?: () => void; + theme: ThemeServiceSetup; + objectType: string; +} + +export type Props = CsvModalProps & { intl: InjectedIntl }; + +export const CsvModalContentUI: FC = (props: Props) => { + const isSaved = Boolean(props.objectId); + const { apiClient, getJobParams, intl, toasts, theme, onClose, objectType, reportType, isDirty } = + props; + const isMounted = useMountedState(); + const [createReportingJob, setCreatingReportJob] = useState(false); + const [absoluteUrl, setAbsoluteUrl] = useState(''); + const exceedsMaxLength = absoluteUrl.length >= getMaxUrlLength(); + + const getAbsoluteReportGenerationUrl = useMemo( + () => () => { + const relativePath = apiClient.getReportingPublicJobPath( + reportType, + apiClient.getDecoratedJobParams(getJobParams()) + ); + return setAbsoluteUrl(url.resolve(window.location.href, relativePath)); + }, + [apiClient, getJobParams, reportType] + ); + + useEffect(() => { + const reportingUrl = new URL(window.location.origin); + reportingUrl.pathname = apiClient.getReportingPublicJobPath( + reportType, + apiClient.getDecoratedJobParams(getJobParams()) + ); + setAbsoluteUrl(reportingUrl.toString()); + }, [getAbsoluteReportGenerationUrl, apiClient, getJobParams, reportType]); + + const generateReportingJob = () => { + const decoratedJobParams = apiClient.getDecoratedJobParams(getJobParams()); + setCreatingReportJob(true); + return apiClient + .createReportingJob(reportType, decoratedJobParams) + .then(() => { + toasts.addSuccess({ + title: intl.formatMessage( + { + id: 'reporting.share.modalContent.successfullyQueuedReportNotificationTitle', + defaultMessage: 'Queued report for {objectType}', + }, + { objectType } + ), + text: toMountPoint( + + + + ), + }} + />, + { theme$: theme.theme$ } + ), + 'data-test-subj': 'queueReportSuccess', + }); + if (onClose) { + onClose(); + } + if (isMounted()) { + setCreatingReportJob(false); + } + }) + .catch((error) => { + toasts.addError(error, { + title: intl.formatMessage({ + id: 'reporting.share.modalContent.notification.reportingErrorTitle', + defaultMessage: 'Unable to create report', + }), + toastMessage: ( + // eslint-disable-next-line react/no-danger + + ) as unknown as string, + }); + if (isMounted()) { + setCreatingReportJob(false); + } + }); + }; + + const renderCopyURLButton = ({ + isUnsaved, + }: { + isUnsaved: boolean; + exceedsMaxLength: boolean; + }) => { + if (isUnsaved && exceedsMaxLength) { + return ; + } else if (exceedsMaxLength) { + return ; + } + return isUnsaved ? ( + <> + + } + > + + {(copy) => ( + + + + )} + + + + } + > + + + + ) : ( + <> + + {(copy) => ( + + + + )} + + + + + + ); + }; + + return ( + <> + + + + + + {renderCopyURLButton({ isUnsaved: !isSaved, exceedsMaxLength })} + {!isSaved ? ( + + generateReportingJob()} + data-test-subj="generateReportButton" + isLoading={Boolean(createReportingJob)} + > + + + + ) : ( + generateReportingJob()} + data-test-subj="generateReportButton" + isLoading={Boolean(createReportingJob)} + > + + + )} + + + ); +}; + +export const CsvModalContent = injectI18n(CsvModalContentUI); diff --git a/packages/kbn-reporting/public/share/share_context_menu/image_export_modal.tsx b/packages/kbn-reporting/public/share/share_context_menu/image_export_modal.tsx new file mode 100644 index 00000000000000..ee844dd685e743 --- /dev/null +++ b/packages/kbn-reporting/public/share/share_context_menu/image_export_modal.tsx @@ -0,0 +1,416 @@ +/* + * 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 { + EuiButton, + EuiIcon, + EuiButtonEmpty, + EuiCopy, + EuiFlexGroup, + EuiModalFooter, + EuiRadioGroup, + EuiSpacer, + EuiSwitch, + EuiSwitchEvent, + EuiToolTip, + EuiText, +} from '@elastic/eui'; +import type { IUiSettingsClient, ThemeServiceSetup, ToastsSetup } from '@kbn/core/public'; +import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n-react'; +import url from 'url'; +import React, { FC, useCallback, useEffect, useState, useMemo } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; +import { LayoutParams } from '@kbn/screenshotting-plugin/common'; +import { BaseParams } from '@kbn/reporting-common/types'; +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { ReportingAPIClient } from '../..'; +import { JobParamsProviderOptions } from '.'; +import { ErrorUrlTooLongPanel } from './reporting_panel_content/components'; +import { getMaxUrlLength } from './reporting_panel_content/constants'; + +export interface ReportingModalProps { + apiClient: ReportingAPIClient; + toasts: ToastsSetup; + uiSettings: IUiSettingsClient; + reportType?: string; + requiresSavedState: boolean; // Whether the report to be generated requires saved state that is not captured in the URL submitted to the report generator. + objectId?: string; + isDirty?: boolean; + onClose: () => void; + theme: ThemeServiceSetup; + layoutOption?: 'print' | 'canvas'; + jobProviderOptions?: JobParamsProviderOptions; + objectType: string; + downloadCsvFromLens?: () => void; +} + +type AppParams = Omit; + +export type Props = ReportingModalProps & { intl: InjectedIntl }; + +type AllowedImageExportType = 'pngV2' | 'printablePdfV2' | 'printablePdf' | 'csv'; + +export const ReportingModalContentUI: FC = (props: Props) => { + const { + apiClient, + intl, + toasts, + theme, + onClose, + objectId, + layoutOption, + jobProviderOptions, + objectType, + isDirty, + downloadCsvFromLens, + } = props; + const isSaved = Boolean(objectId) || !isDirty; + const [isStale, setIsStale] = useState(false); + const [createReportingJob, setCreatingReportJob] = useState(false); + const [selectedRadio, setSelectedRadio] = useState('printablePdfV2'); + const [usePrintLayout, setPrintLayout] = useState(false); + const [absoluteUrl, setAbsoluteUrl] = useState(''); + const isMounted = useMountedState(); + const exceedsMaxLength = absoluteUrl.length >= getMaxUrlLength(); + + const getJobsParams = useCallback( + (type: AllowedImageExportType, opts?: JobParamsProviderOptions) => { + if (!opts) { + return; + } + + const { + sharingData: { title, layout, locatorParams }, + } = opts; + + const baseParams = { + objectType, + layout, + title, + }; + + if (type === 'printablePdfV2') { + // multi locator for PDF V2 + return { ...baseParams, locatorParams: [locatorParams] }; + } else if (type === 'pngV2') { + // single locator for PNG V2 + return { ...baseParams, locatorParams }; + } + + // Relative URL must have URL prefix (Spaces ID prefix), but not server basePath + // Replace hashes with original RISON values. + const relativeUrl = opts?.shareableUrl.replace( + window.location.origin + apiClient.getServerBasePath(), + '' + ); + + if (type === 'printablePdf') { + // multi URL for PDF + return { ...baseParams, relativeUrls: [relativeUrl] }; + } + + // single URL for PNG + return { ...baseParams, relativeUrl }; + }, + [apiClient, objectType] + ); + + const getLayout = useCallback((): LayoutParams => { + const el = document.querySelector('[data-shared-items-container]'); + const { height, width } = el ? el.getBoundingClientRect() : { height: 768, width: 1024 }; + const dimensions = { height, width }; + + if (usePrintLayout) { + return { id: 'print', dimensions }; + } + + return { id: 'preserve_layout', dimensions }; + }, [usePrintLayout]); + + const getJobParams = useCallback( + (shareableUrl?: boolean) => { + return { ...getJobsParams(selectedRadio, jobProviderOptions), layout: getLayout() }; + }, + [getJobsParams, getLayout, jobProviderOptions, selectedRadio] + ); + + const getAbsoluteReportGenerationUrl = useMemo( + () => () => { + if (getJobsParams(selectedRadio, jobProviderOptions) !== undefined) { + const relativePath = apiClient.getReportingPublicJobPath( + selectedRadio, + apiClient.getDecoratedJobParams(getJobParams(true) as unknown as AppParams) + ); + return setAbsoluteUrl(url.resolve(window.location.href, relativePath)); + } + }, + [apiClient, getJobParams, selectedRadio, getJobsParams, jobProviderOptions] + ); + + const markAsStale = useCallback(() => { + if (!isMounted) return; + setIsStale(true); + }, [isMounted]); + + useEffect(() => { + getAbsoluteReportGenerationUrl(); + markAsStale(); + }, [markAsStale, getAbsoluteReportGenerationUrl]); + + const generateReportingJob = () => { + if (selectedRadio === 'csv' && downloadCsvFromLens) { + return downloadCsvFromLens(); + } + const decoratedJobParams = apiClient.getDecoratedJobParams( + getJobParams(false) as unknown as AppParams + ); + setCreatingReportJob(true); + return apiClient + .createReportingJob(selectedRadio, decoratedJobParams) + .then(() => { + toasts.addSuccess({ + title: intl.formatMessage( + { + id: 'reporting.modalContent.successfullyQueuedReportNotificationTitle', + defaultMessage: 'Queued report for {objectType}', + }, + { objectType } + ), + text: toMountPoint( + + + + ), + }} + />, + { theme$: theme.theme$ } + ), + 'data-test-subj': 'queueReportSuccess', + }); + if (onClose) { + onClose(); + } + if (isMounted()) { + setCreatingReportJob(false); + } + }) + .catch((error) => { + toasts.addError(error, { + title: intl!.formatMessage({ + id: 'reporting.modalContent.notification.reportingErrorTitle', + defaultMessage: 'Unable to create report', + }), + toastMessage: ( + // eslint-disable-next-line react/no-danger + + ) as unknown as string, + }); + if (isMounted()) { + setCreatingReportJob(false); + } + }); + }; + + const handlePrintLayoutChange = (evt: EuiSwitchEvent) => { + setPrintLayout(evt.target.checked); + }; + + const renderOptions = () => { + if (layoutOption === 'print' && selectedRadio !== 'pngV2') { + return ( + <> + + + + } + css={{ display: 'block' }} + checked={usePrintLayout} + onChange={handlePrintLayoutChange} + data-test-subj="usePrintLayout" + /> + + } + > + + + + ); + } + return null; + }; + const renderCopyURLButton = useCallback(() => { + if (exceedsMaxLength) { + return ; + } + + return ( + <> + + ) : ( + + ) + } + > + + {(copy) => ( + + + + )} + + + + } + > + + + + ); + }, [absoluteUrl, exceedsMaxLength, isDirty]); + + const saveWarningMessageWithButton = + objectId === undefined || objectId === '' || !isSaved || isDirty || isStale ? ( + <> + + generateReportingJob()} + data-test-subj="generateReportButton" + isLoading={Boolean(createReportingJob)} + > + + + + + ) : ( + generateReportingJob()} + data-test-subj="generateReportButton" + isLoading={Boolean(createReportingJob)} + > + + + ); + + const radioOptions = + objectType === 'lens' + ? [ + { id: 'printablePdfV2', label: 'PDF' }, + { id: 'pngV2', label: 'PNG', 'data-test-subj': 'pngReportOption' }, + { id: 'csv', label: 'CSV', 'data-test-subj': 'lensCSVReport' }, + ] + : [ + { id: 'printablePdfV2', label: 'PDF' }, + { id: 'pngV2', label: 'PNG', 'data-test-subj': 'pngReportOption' }, + ]; + return ( + <> + + + + + { + setSelectedRadio(id as Exclude); + }} + name="image reporting radio group" + idSelected={selectedRadio} + legend={{ + children: , + }} + /> + + + + {renderOptions()} + {renderCopyURLButton()} + {objectType === 'dashboard' ? ( + generateReportingJob()} + data-test-subj="generateReportButton" + isLoading={Boolean(createReportingJob)} + > + + + + + ) : ( + saveWarningMessageWithButton + )} + + + ); +}; + +export const ReportingModalContent = injectI18n(ReportingModalContentUI); diff --git a/packages/kbn-reporting/public/share/share_context_menu/index.ts b/packages/kbn-reporting/public/share/share_context_menu/index.ts index 22503eae119cb1..134fff4f5e1375 100644 --- a/packages/kbn-reporting/public/share/share_context_menu/index.ts +++ b/packages/kbn-reporting/public/share/share_context_menu/index.ts @@ -16,6 +16,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; + toasts: ToastsSetup; + uiSettings: IUiSettingsClient; + usesUiCapabilities: boolean; + license: ILicense; + application: ApplicationStart; + theme: ThemeServiceSetup; +} + export interface ExportPanelShareOpts { apiClient: ReportingAPIClient; toasts: ToastsSetup; diff --git a/packages/kbn-reporting/public/share/share_context_menu/register_csv_reporting.tsx b/packages/kbn-reporting/public/share/share_context_menu/register_csv_reporting.tsx index 2de8abe723a865..617c8c3518f04d 100644 --- a/packages/kbn-reporting/public/share/share_context_menu/register_csv_reporting.tsx +++ b/packages/kbn-reporting/public/share/share_context_menu/register_csv_reporting.tsx @@ -13,19 +13,17 @@ import { CSV_JOB_TYPE, CSV_JOB_TYPE_V2 } from '@kbn/reporting-export-types-csv-c import type { SearchSourceFields } from '@kbn/data-plugin/common'; import { ShareContext, ShareMenuProvider } from '@kbn/share-plugin/public'; -import type { ExportPanelShareOpts } from '.'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { ExportModalShareOpts } from '.'; import { checkLicense } from '../..'; -import { ReportingPanelContent } from './reporting_panel_content_lazy'; +import { generateReportingJobCSV } from '../shared/get_report_generate'; export const reportingCsvShareProvider = ({ apiClient, - toasts, - uiSettings, application, license, usesUiCapabilities, - theme, -}: ExportPanelShareOpts): ShareMenuProvider => { +}: ExportModalShareOpts): ShareMenuProvider => { const getShareMenuItems = ({ objectType, objectId, sharingData, onClose }: ShareContext) => { if ('search' !== objectType) { return []; @@ -75,7 +73,6 @@ export const reportingCsvShareProvider = ({ const licenseHasCsvReporting = licenseCheck.showLinks; const licenseDisabled = !licenseCheck.enableLinks; - // TODO: add abstractions in ExportTypeRegistry to use here? let capabilityHasCsvReporting = false; if (usesUiCapabilities) { capabilityHasCsvReporting = application.capabilities.discover?.generateCsv === true; @@ -85,36 +82,40 @@ export const reportingCsvShareProvider = ({ if (licenseHasCsvReporting && capabilityHasCsvReporting) { const panelTitle = i18n.translate('reporting.share.contextMenu.csvReportsButtonLabel', { - defaultMessage: 'CSV Reports', + defaultMessage: 'Export', }); shareActions.push({ shareMenuItem: { name: panelTitle, - icon: 'document', toolTipContent: licenseToolTipContent, disabled: licenseDisabled, - ['data-test-subj']: 'CSVReports', - sortOrder: 1, + ['data-test-subj']: 'Export', }, - panel: { - id: 'csvReportingPanel', - title: panelTitle, - content: ( - - ), + tabType: 'Export', + helpText: ( + + ), + reportType: [reportType], + copyURLButton: { + id: 'reporting.share.modalContent.csv.copyUrlButtonLabel', + dataTestSubj: 'shareReportingCopyURL', + label: 'Post URL', }, + generateReportButton: ( + + ), + getJobParams: [{ id: reportType, handler: getJobParams }], + createReportingJob: generateReportingJobCSV, + reportingAPIClient: apiClient, }); } diff --git a/packages/kbn-reporting/public/share/share_context_menu/register_pdf_png_reporting.tsx b/packages/kbn-reporting/public/share/share_context_menu/register_pdf_png_reporting.tsx index cd75ab11e764cd..aa9e882765b604 100644 --- a/packages/kbn-reporting/public/share/share_context_menu/register_pdf_png_reporting.tsx +++ b/packages/kbn-reporting/public/share/share_context_menu/register_pdf_png_reporting.tsx @@ -7,11 +7,19 @@ */ import { i18n } from '@kbn/i18n'; -import { ShareContext, ShareMenuProvider } from '@kbn/share-plugin/public'; import React from 'react'; -import { ExportPanelShareOpts, JobParamsProviderOptions, ReportingSharingData } from '.'; -import { ReportingAPIClient, checkLicense } from '../..'; +import { ShareContext, ShareMenuProvider } from '@kbn/share-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { checkLicense } from '../../license_check'; +import { + ExportModalShareOpts, + ExportPanelShareOpts, + JobParamsProviderOptions, + ReportingSharingData, +} from '.'; import { ScreenCapturePanelContent } from './screen_capture_panel_content_lazy'; +import { ReportingAPIClient } from '../../reporting_api_client'; +import { generateReportingJobPNGPDF } from '../shared/get_report_generate'; const getJobParams = ( @@ -55,6 +63,9 @@ const getJobParams = return { ...baseParams, relativeUrl }; }; +/** + * This is used by Canvas + */ export const reportingScreenshotShareProvider = ({ apiClient, toasts, @@ -210,3 +221,123 @@ export const reportingScreenshotShareProvider = ({ getShareMenuItems, }; }; + +export const isJobV2Params = ({ sharingData }: { sharingData: Record }): boolean => + sharingData.locatorParams != null; + +export const reportingExportModalProvider = ({ + apiClient, + license, + application, + usesUiCapabilities, + toasts, + theme, +}: ExportModalShareOpts): 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 []; + } + // 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 = []; + + const jobProviderOptions: JobParamsProviderOptions = { + shareableUrl: isDirty ? shareableUrl : shareableUrlForSavedObject ?? shareableUrl, + objectType, + sharingData, + }; + + const isV2Job = isJobV2Params(jobProviderOptions); + const requiresSavedState = !isV2Job; + + shareActions.push({ + shareMenuItem: { + name: i18n.translate('reporting.shareContextMenu.ExportsButtonLabel', { + defaultMessage: 'Export', + }), + toolTipContent: licenseToolTipContent, + disabled: licenseDisabled || sharingData.reportingDisabled, + ['data-test-subj']: 'imageExports', + }, + tabType: 'Export', + jobProviderOptions, + reportingAPIClient: apiClient, + reportType: ['printablePdfV2', 'pngV2'], + requiresSavedState, + helpText: ( + + ), + generateReportButton: ( + + ), + createReportingJob: generateReportingJobPNGPDF, + getJobParams: [ + { id: 'pngV2', handler: getJobParams(apiClient, jobProviderOptions, 'pngV2') }, + { + id: 'printablePdfV2', + handler: getJobParams(apiClient, jobProviderOptions, 'printablePdfV2'), + }, + ], + layoutOption: objectType === 'dashboard' ? ('print' as const) : undefined, + toasts, + theme, + }); + + return shareActions; + }; + + return { + id: 'screenCaptureReports', + getShareMenuItems, + }; +}; diff --git a/packages/kbn-reporting/public/share/share_context_menu/reporting_modal_content_lazy.tsx b/packages/kbn-reporting/public/share/share_context_menu/reporting_modal_content_lazy.tsx new file mode 100644 index 00000000000000..447975042ec5b7 --- /dev/null +++ b/packages/kbn-reporting/public/share/share_context_menu/reporting_modal_content_lazy.tsx @@ -0,0 +1,26 @@ +/* + * 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 * as React from 'react'; +import { FC, lazy, Suspense } from 'react'; +import { PanelSpinner } from './panel_spinner'; +import type { ReportingModalProps } from './image_export_modal'; + +const LazyModalComponent = lazy(() => + import('./image_export_modal').then(({ ReportingModalContent }) => ({ + default: ReportingModalContent, + })) +); + +export const ReportingModalContent: FC = (props) => { + return ( + }> + + + ); +}; diff --git a/packages/kbn-reporting/public/share/share_context_menu/screen_capture_panel_content.test.tsx b/packages/kbn-reporting/public/share/share_context_menu/screen_capture_panel_content.test.tsx index 42d599da196226..b9fe7e97aed5ae 100644 --- a/packages/kbn-reporting/public/share/share_context_menu/screen_capture_panel_content.test.tsx +++ b/packages/kbn-reporting/public/share/share_context_menu/screen_capture_panel_content.test.tsx @@ -30,30 +30,10 @@ const getJobParamsDefault = () => ({ const theme = themeServiceMock.createSetupContract(); -test('ScreenCapturePanelContent renders the default view properly', () => { - const component = mount( - - - - ); - expect(component.find('EuiForm').render()).toMatchSnapshot(); - expect(component.text()).not.toMatch('Full page layout'); - expect(component.text()).not.toMatch('Optimize for printing'); -}); - test('ScreenCapturePanelContent properly renders a view with "canvas" layout option', () => { const component = mount( { - const component = mount( - - - - ); - expect(component.find('EuiForm').render()).toMatchSnapshot(); - expect(component.text()).toMatch('Optimize for printing'); -}); - test('ScreenCapturePanelContent decorated job params are visible in the POST URL', () => { const component = mount( @@ -144,6 +103,6 @@ test('ScreenCapturePanelContent decorated job params are visible in the POST URL ); expect(component.find('EuiCopy').prop('textToCopy')).toMatchInlineSnapshot( - `"http://localhost/api/reporting/generate/Analytical%20App?jobParams=%28browserTimezone%3AAmerica%2FNew_York%2Clayout%3A%28dimensions%3A%28height%3A768%2Cwidth%3A1024%29%2Cid%3Apreserve_layout%29%2CobjectType%3Atest-object-type%2Ctitle%3A%27Test%20Report%20Title%27%2Cversion%3A%277.15.0%27%29"` + `"http://localhost/api/reporting/generate/Analytical%20App?jobParams=%28browserTimezone%3AAmerica%2FNew_York%2Clayout%3A%28dimensions%3A%28height%3A768%2Cwidth%3A1024%29%2Cid%3Acanvas%29%2CobjectType%3Atest-object-type%2Ctitle%3A%27Test%20Report%20Title%27%2Cversion%3A%277.15.0%27%29"` ); }); diff --git a/packages/kbn-reporting/public/share/share_context_menu/screen_capture_panel_content_lazy.tsx b/packages/kbn-reporting/public/share/share_context_menu/screen_capture_panel_content_lazy.tsx index e153dd4dcb0fe4..9c59b5fa53d27a 100644 --- a/packages/kbn-reporting/public/share/share_context_menu/screen_capture_panel_content_lazy.tsx +++ b/packages/kbn-reporting/public/share/share_context_menu/screen_capture_panel_content_lazy.tsx @@ -17,7 +17,7 @@ const LazyComponent = lazy(() => })) ); -export const ScreenCapturePanelContent: FC = (props) => { +export const ScreenCapturePanelContent: FC> = (props) => { return ( }> diff --git a/packages/kbn-reporting/public/share/shared/get_report_generate.tsx b/packages/kbn-reporting/public/share/shared/get_report_generate.tsx new file mode 100644 index 00000000000000..fdbbd6a7759182 --- /dev/null +++ b/packages/kbn-reporting/public/share/shared/get_report_generate.tsx @@ -0,0 +1,122 @@ +/* + * 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 { ThemeServiceSetup, ToastsSetup } from '@kbn/core/public'; +import { FormattedMessage, InjectedIntl } from '@kbn/i18n-react'; +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import React from 'react'; +import type { ReportingAPIClient } from '../..'; + +export const generateReportingJobPNGPDF = () => { + 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( + + + + ), + }} + />, + { theme$: theme.theme$ } + ), + 'data-test-subj': 'queueReportSuccess', + }); + if (onClose) { + onClose(); + } + }) + .catch((error: any) => { + toasts.addError(error, { + title: intl.formatMessage({ + id: 'reporting.share.modalContent.notification.reportingErrorTitle', + defaultMessage: 'Unable to create report', + }), + toastMessage: ( + // eslint-disable-next-line react/no-danger + + ) as unknown as string, + }); + }); +}; + +export const generateReportingJobCSV = ( + intl: InjectedIntl, + apiClient: ReportingAPIClient, + getJobParams: () => any, + reportType: string, + toasts: ToastsSetup, + objectType: string, + onClose: () => void, + theme: ThemeServiceSetup +) => { + 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( + + + + ), + }} + />, + { theme$: theme.theme$ } + ), + 'data-test-subj': 'queueReportSuccess', + }); + if (onClose) { + onClose(); + } + }) + .catch((error) => { + toasts.addError(error, { + title: intl.formatMessage({ + id: 'reporting.share.modalContent.notification.reportingErrorTitle', + defaultMessage: 'Unable to create report', + }), + toastMessage: ( + // eslint-disable-next-line react/no-danger + + ) as unknown as string, + }); + }); +}; diff --git a/packages/kbn-reporting/public/share/shared/get_shared_components.tsx b/packages/kbn-reporting/public/share/shared/get_shared_components.tsx index 0fc0f2ad10ab3a..83841fb0ef867d 100644 --- a/packages/kbn-reporting/public/share/shared/get_shared_components.tsx +++ b/packages/kbn-reporting/public/share/shared/get_shared_components.tsx @@ -7,23 +7,24 @@ */ import { CoreSetup } from '@kbn/core/public'; +import { BaseParams } from '@kbn/reporting-common/types'; +import { CSV_JOB_TYPE, JobParamsCSV } from '@kbn/reporting-export-types-csv-common'; +import { JobAppParamsPDFV2, PDF_REPORT_TYPE_V2 } from '@kbn/reporting-export-types-pdf-common'; import React from 'react'; - -import { PDF_REPORT_TYPE, 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 { ReportingAPIClient } from '../..'; +import { CsvModalContent } from '../share_context_menu/csv_export_modal'; import { ReportingPanelProps } from '../share_context_menu/reporting_panel_content'; +import { ReportingModalContent } from '../share_context_menu/reporting_modal_content_lazy'; 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']; + getJobParams?: JobAppParamsPDFV2 | JobParamsCSV | ReportingPanelProps['getJobParams']; /** * Option to control how the screenshot(s) is/are placed in the PDF @@ -39,27 +40,27 @@ export interface ApplicationProps { * A function to callback when the Reporting panel should be closed */ onClose: () => void; + objectType: string; + downloadCsvFromLens?: () => void; } -/** - * React components used to display share menus with Reporting features in an application. - */ export interface ReportingPublicComponents { + /** Needed for Canvas PDF reports */ + ReportingPanelPDFV2(props: ApplicationProps): JSX.Element | undefined; /** * An element to display a form to export the page as PDF - * @deprecated */ - ReportingPanelPDF(props: ApplicationProps): JSX.Element; + ReportingModalPDFV2(props: ApplicationProps): JSX.Element; /** - * An element to display a form to export the page as PDF + * An element to display a form to export the page as PNG */ - ReportingPanelPDFV2(props: ApplicationProps): JSX.Element; + ReportingModalPNGV2(props: ApplicationProps): JSX.Element; /** - * An element to display a form to export the page as PNG + * An element to display a form to export the page as CSV */ - ReportingPanelPNGV2(props: ApplicationProps): JSX.Element; + ReportingModalCSV(props: ApplicationProps): JSX.Element; } /** @@ -72,11 +73,28 @@ export function getSharedComponents( apiClient: ReportingAPIClient ): ReportingPublicComponents { return { - ReportingPanelPDF(props: ApplicationProps) { + ReportingPanelPDFV2(props: ApplicationProps) { + const getJobParams = props.getJobParams as ReportingPanelProps['getJobParams']; + if (props.layoutOption === 'canvas') { + return ( + + ); + } + }, + ReportingModalPDFV2(props: ApplicationProps) { return ( - ); }, - ReportingPanelPDFV2(props: ApplicationProps) { + ReportingModalPNGV2(props: ApplicationProps) { return ( - ); }, - ReportingPanelPNGV2(props: ApplicationProps) { + ReportingModalCSV(props: ApplicationProps) { + const getJobParams = props.getJobParams as ( + forShareUrl?: boolean + ) => Omit; return ( - ); }, diff --git a/packages/kbn-reporting/public/tsconfig.json b/packages/kbn-reporting/public/tsconfig.json index 83f8f2f9d5d5ea..5f19085403f4a4 100644 --- a/packages/kbn-reporting/public/tsconfig.json +++ b/packages/kbn-reporting/public/tsconfig.json @@ -30,11 +30,7 @@ "@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", + "@kbn/lens-plugin", + "@kbn/field-formats-plugin", ] } diff --git a/packages/shared-ux/modal/impl/src/share_modal.stories.tsx b/packages/shared-ux/modal/impl/src/share_modal.stories.tsx new file mode 100644 index 00000000000000..5c2d5b68ae2e03 --- /dev/null +++ b/packages/shared-ux/modal/impl/src/share_modal.stories.tsx @@ -0,0 +1,7 @@ +/* + * 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. + */ diff --git a/packages/shared-ux/modal/impl/tsconfig.json b/packages/shared-ux/modal/impl/tsconfig.json new file mode 100644 index 00000000000000..b40bf1cd5de662 --- /dev/null +++ b/packages/shared-ux/modal/impl/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react", + "@kbn/ambient-ui-types", + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "kbn_references": [ + ], + "exclude": [ + "target/**/*", + ] +} diff --git a/packages/shared-ux/modal/mocks/index.ts b/packages/shared-ux/modal/mocks/index.ts new file mode 100644 index 00000000000000..dd836d1d0ed578 --- /dev/null +++ b/packages/shared-ux/modal/mocks/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { StorybookMock as ShareModalStorybookMock } from './src/storybook'; +export type { Params as ShareModalStorybookParams } from './src/storybook'; diff --git a/packages/shared-ux/modal/mocks/tsconfig.json b/packages/shared-ux/modal/mocks/tsconfig.json new file mode 100644 index 00000000000000..c9bef4516921f2 --- /dev/null +++ b/packages/shared-ux/modal/mocks/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*", + ], + "kbn_references": [ + ] +} diff --git a/packages/shared-ux/modal/tabbed/index.tsx b/packages/shared-ux/modal/tabbed/index.tsx new file mode 100644 index 00000000000000..6620f2633b5560 --- /dev/null +++ b/packages/shared-ux/modal/tabbed/index.tsx @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { TabbedModal } from './src/tabbed_modal'; +export type { IModalTabDeclaration } from './src/tabbed_modal'; diff --git a/packages/shared-ux/modal/tabbed/kibana.jsonc b/packages/shared-ux/modal/tabbed/kibana.jsonc new file mode 100644 index 00000000000000..4abd1fe7543edc --- /dev/null +++ b/packages/shared-ux/modal/tabbed/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/shared-ux-tabbed-modal", + "owner": "@elastic/appex-sharedux" + } \ No newline at end of file diff --git a/packages/shared-ux/modal/tabbed/package.json b/packages/shared-ux/modal/tabbed/package.json new file mode 100644 index 00000000000000..987db5ef12407f --- /dev/null +++ b/packages/shared-ux/modal/tabbed/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/shared-ux-tabbed-modal", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" + } \ No newline at end of file diff --git a/packages/shared-ux/modal/tabbed/src/context/index.tsx b/packages/shared-ux/modal/tabbed/src/context/index.tsx new file mode 100644 index 00000000000000..9e7f6d7effb839 --- /dev/null +++ b/packages/shared-ux/modal/tabbed/src/context/index.tsx @@ -0,0 +1,157 @@ +/* + * 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, { + createContext, + useContext, + useReducer, + useMemo, + useRef, + useCallback, + type PropsWithChildren, + type Dispatch, +} from 'react'; +import { once } from 'lodash'; + +interface IDispatchAction { + type: string; + payload: any; +} + +export type IDispatchFunction = Dispatch; + +export interface IMetaState { + selectedTabId: string | null; +} + +type IReducer = (state: S, action: IDispatchAction) => S; + +export interface ITabDeclaration { + id: string; + name: string; + initialState?: Partial; + reducer?: IReducer; +} + +interface IModalContext>>> { + tabs: Array>; + state: { + meta: IMetaState; + [index: string]: any; + }; + dispatch?: Dispatch; +} + +const createStateContext = once(>>>() => + createContext({ + tabs: [], + state: { + meta: { + selectedTabId: null, + }, + }, + dispatch: () => {}, + } as IModalContext) +); + +export const useModalContext = >>>() => + useContext(createStateContext()); + +/** + * @description defines state transition for meta information to manage the modal, meta action types + * must be prefixed with the string 'META_' + */ +const modalMetaReducer: IReducer = (state, action) => { + switch (action.type) { + case 'META_selectedTabId': + return { + ...state, + selectedTabId: action.payload as string, + }; + default: + return state; + } +}; + +export type IModalContextProviderProps>>> = + PropsWithChildren<{ + /** + * Array of tab declaration to be rendered into the modal that will be rendered + */ + tabs: Tabs; + /** + * ID of the tab we'd like the modal to have selected on render + */ + defaultSelectedTabId: Tabs[number]['id']; + }>; + +export function ModalContextProvider>>>({ + tabs, + defaultSelectedTabId, + children, +}: IModalContextProviderProps) { + const ModalContext = createStateContext(); + + type IModalInstanceContext = IModalContext; + + const modalTabDefinitions = useRef([]); + + const initialModalState = useRef({ + // instantiate state with default meta information + meta: { + selectedTabId: defaultSelectedTabId, + }, + }); + + const reducersMap = useMemo( + () => + tabs.reduce((result, { reducer, initialState, ...rest }) => { + initialModalState.current[rest.id] = initialState ?? {}; + // @ts-ignore + modalTabDefinitions.current.push({ ...rest }); + result[rest.id] = reducer; + return result; + }, {} as Record), + [tabs] + ); + + const combineReducers = useCallback(function (reducers: Record) { + return (state: IModalInstanceContext['state'], action: IDispatchAction) => { + const newState = { ...state }; + + if (/^meta_/i.test(action.type)) { + newState.meta = modalMetaReducer(newState.meta, action); + } else { + const selectedTabId = state.meta.selectedTabId!; + const selectedTabReducer = reducers[selectedTabId]; + + if (selectedTabReducer) { + newState[selectedTabId] = selectedTabReducer(newState[selectedTabId], action); + } + } + + return newState; + }; + }, []); + + const createInitialState = useCallback((state: IModalInstanceContext['state']) => { + return state; + }, []); + + const [state, dispatch] = useReducer( + combineReducers(reducersMap), + initialModalState.current, + createInitialState + ); + + return ( + + {children} + + ); +} diff --git a/packages/shared-ux/modal/tabbed/src/tabbed_modal.tsx b/packages/shared-ux/modal/tabbed/src/tabbed_modal.tsx new file mode 100644 index 00000000000000..cb53a86310efb9 --- /dev/null +++ b/packages/shared-ux/modal/tabbed/src/tabbed_modal.tsx @@ -0,0 +1,147 @@ +/* + * 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, { + useMemo, + Fragment, + type ComponentProps, + type FC, + type ReactElement, + useCallback, +} from 'react'; +import { + EuiButton, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiTabs, + EuiTab, + type EuiTabProps, + type CommonProps, +} from '@elastic/eui'; +import { + ModalContextProvider, + useModalContext, + type ITabDeclaration, + type IDispatchFunction, + type IModalContextProviderProps, +} from './context'; + +export type IModalTabContent = (props: { + state?: S; + dispatch?: IDispatchFunction; +}) => ReactElement; + +interface IModalTabActionBtn extends CommonProps { + id: string; + dataTestSubj: string; + label: string; + handler?: (args: { state: S }) => void; + isCopy?: boolean; +} + +export interface IModalTabDeclaration extends EuiTabProps, ITabDeclaration { + description?: string; + 'data-test-subj'?: string; + content: IModalTabContent; + modalActionBtn?: IModalTabActionBtn; +} + +interface ITabbedModalInner extends Pick, 'onClose'> { + modalWidth?: number; + modalTitle?: string; +} + +const TabbedModalInner: FC = ({ onClose, modalTitle, modalWidth }) => { + const { tabs, state, dispatch } = + useModalContext>>>(); + + const selectedTabId = state.meta.selectedTabId; + const selectedTabState = useMemo( + () => (selectedTabId ? state[selectedTabId] : {}), + [selectedTabId, state] + ); + + const { content: SelectedTabContent, modalActionBtn } = useMemo(() => { + return tabs.find((obj) => obj.id === selectedTabId)!; + }, [selectedTabId, tabs]); + + const onSelectedTabChanged = useCallback( + (id: string) => { + dispatch!({ type: 'META_selectedTabId', payload: id }); + }, + [dispatch] + ); + + const renderTabs = useCallback(() => { + return tabs.map((tab, index) => { + return ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + disabled={tab.disabled} + prepend={tab.prepend} + append={tab.append} + > + {tab.name} + + ); + }); + }, [onSelectedTabChanged, selectedTabId, tabs]); + + return ( + + + {modalTitle} + + + + {renderTabs()} + {React.createElement(SelectedTabContent, { + state: selectedTabState, + dispatch, + })} + + + {modalActionBtn && ( + + { + // @ts-ignore state will not be null because of the modalActionBtn check + modalActionBtn!.handler!({ state: selectedTabId }); + }} + > + {modalActionBtn.label} + + + )} + + ); +}; + +export function TabbedModal>>({ + tabs, + defaultSelectedTabId, + ...rest +}: Omit, 'children'> & ITabbedModalInner) { + return ( + + + + ); +} diff --git a/packages/shared-ux/modal/tabbed/tsconfig.json b/packages/shared-ux/modal/tabbed/tsconfig.json new file mode 100644 index 00000000000000..c9bef4516921f2 --- /dev/null +++ b/packages/shared-ux/modal/tabbed/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*", + ], + "kbn_references": [ + ] +} diff --git a/src/plugins/share/kibana.jsonc b/src/plugins/share/kibana.jsonc index 175264cab790b3..083b3f3f03238f 100644 --- a/src/plugins/share/kibana.jsonc +++ b/src/plugins/share/kibana.jsonc @@ -7,6 +7,8 @@ "id": "share", "server": true, "browser": true, - "requiredBundles": ["kibanaReact", "kibanaUtils"], + "requiredBundles": [ + "kibanaUtils", + ] } } diff --git a/src/plugins/share/public/components/context/index.tsx b/src/plugins/share/public/components/context/index.tsx new file mode 100644 index 00000000000000..74822b180d73d3 --- /dev/null +++ b/src/plugins/share/public/components/context/index.tsx @@ -0,0 +1,38 @@ +/* + * 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 { ThemeServiceSetup } from '@kbn/core-theme-browser'; +import { I18nStart, ToastsSetup } from '@kbn/core/public'; +import { createContext, useContext } from 'react'; + +import { AnonymousAccessServiceContract } from '../../../common'; +import type { + ShareMenuItem, + UrlParamExtension, + BrowserUrlService, + ShareContext, +} from '../../types'; + +export interface IShareContext extends ShareContext { + allowEmbed: boolean; + allowShortUrl: boolean; + shareMenuItems: ShareMenuItem[]; + embedUrlParamExtensions?: UrlParamExtension[]; + anonymousAccess?: AnonymousAccessServiceContract; + urlService: BrowserUrlService; + snapshotShareWarning?: string; + objectTypeTitle?: string; + isEmbedded: boolean; + theme: ThemeServiceSetup; + i18n: I18nStart; + toasts: ToastsSetup; +} + +export const ShareTabsContext = createContext(null); + +export const useShareTabsContext = () => useContext(ShareTabsContext); diff --git a/src/plugins/share/public/components/share_tabs.tsx b/src/plugins/share/public/components/share_tabs.tsx index 9c28cf6dc72fc6..f2b0ad7f5bd422 100644 --- a/src/plugins/share/public/components/share_tabs.tsx +++ b/src/plugins/share/public/components/share_tabs.tsx @@ -6,44 +6,48 @@ * Side Public License, v 1. */ -import { Capabilities } from '@kbn/core-capabilities-common'; -import React from 'react'; -import { EuiModal } from '@elastic/eui'; -import { LocatorPublic, AnonymousAccessServiceContract } from '../../common'; -import { ShareMenuItem, UrlParamExtension, BrowserUrlService } from '../types'; - -export interface ModalTabActionHandler { - id: string; - dataTestSubj: string; - formattedMessageId: string; - defaultMessage: string; -} - -export interface ShareContextTabProps { - allowEmbed: boolean; - allowShortUrl: boolean; - objectId?: string; - objectType: string; - shareableUrl?: string; - shareableUrlForSavedObject?: string; - shareableUrlLocatorParams?: { - locator: LocatorPublic; - params: any; - }; - shareMenuItems: ShareMenuItem[]; - sharingData: any; - onClose: () => void; - embedUrlParamExtensions?: UrlParamExtension[]; - anonymousAccess?: AnonymousAccessServiceContract; - showPublicUrlSwitch?: (anonymousUserCapabilities: Capabilities) => boolean; - urlService: BrowserUrlService; - snapshotShareWarning?: string; - objectTypeTitle?: string; - disabledShareUrl?: boolean; - isDirty: boolean; - isEmbedded: boolean; -} - -export const ShareMenuTabs = ({ onClose }: ShareContextTabProps) => { - return {'placeholder'}; +import React, { type FC } from 'react'; +import { TabbedModal } from '@kbn/shared-ux-tabbed-modal'; + +import { ShareTabsContext, useShareTabsContext, type IShareContext } from './context'; +import { linkTab, embedTab, exportTab } from './tabs'; + +export const ShareMenuV2: FC<{ shareContext: IShareContext }> = ({ shareContext }) => { + return ( + + + + ); +}; + +// this file is intended to replace share_context_menu +export const ShareMenuTabs = () => { + const shareContext = useShareTabsContext(); + + if (!shareContext) { + return null; + } + + const { allowEmbed, objectType, onClose, shareMenuItems } = shareContext; + const tabs = []; + + tabs.push(linkTab); + + if (shareMenuItems) { + tabs.push(exportTab); + } + + if (allowEmbed) { + tabs.push(embedTab); + } + + return ( + + ); }; diff --git a/src/plugins/share/public/components/tabs/embed/embed_content.tsx b/src/plugins/share/public/components/tabs/embed/embed_content.tsx new file mode 100644 index 00000000000000..380dea99fb4785 --- /dev/null +++ b/src/plugins/share/public/components/tabs/embed/embed_content.tsx @@ -0,0 +1,233 @@ +/* + * 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 { EuiForm, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useCallback, useEffect, useState } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; +import { format as formatUrl, parse as parseUrl } from 'url'; +import { AnonymousAccessState } from '../../../../common'; + +import { type IShareContext } from '../../context'; + +type EmbedProps = Pick< + IShareContext, + | 'shareableUrlLocatorParams' + | 'shareableUrlForSavedObject' + | 'shareableUrl' + | 'isEmbedded' + | 'embedUrlParamExtensions' +> & { + onChange: (url: string) => void; +}; + +interface UrlParams { + [extensionName: string]: { + [queryParam: string]: boolean; + }; +} + +export enum ExportUrlAsType { + EXPORT_URL_AS_SAVED_OBJECT = 'savedObject', + EXPORT_URL_AS_SNAPSHOT = 'snapshot', +} + +export const EmbedContent = ({ + embedUrlParamExtensions: urlParamExtensions, + shareableUrlForSavedObject, + shareableUrl, + isEmbedded, + onChange, +}: EmbedProps) => { + const isMounted = useMountedState(); + const [urlParams, setUrlParams] = useState(undefined); + const [useShortUrl] = useState(true); + const [exportUrlAs] = useState(ExportUrlAsType.EXPORT_URL_AS_SAVED_OBJECT); + const [url, setUrl] = useState(''); + const [shortUrlCache, setShortUrlCache] = useState(undefined); + const [anonymousAccessParameters] = useState(null); + const [usePublicUrl] = useState(false); + + const getUrlParamExtensions = useCallback( + (tempUrl: string): string => { + return urlParams + ? Object.keys(urlParams).reduce((urlAccumulator, key) => { + const urlParam = urlParams[key]; + return urlParam + ? Object.keys(urlParam).reduce((queryAccumulator, queryParam) => { + const isQueryParamEnabled = urlParam[queryParam]; + return isQueryParamEnabled + ? queryAccumulator + `&${queryParam}=true` + : queryAccumulator; + }, urlAccumulator) + : urlAccumulator; + }, tempUrl) + : tempUrl; + }, + [urlParams] + ); + + const updateUrlParams = useCallback( + (tempUrl: string) => { + tempUrl = urlParams ? getUrlParamExtensions(tempUrl) : tempUrl; + return tempUrl; + }, + [getUrlParamExtensions, urlParams] + ); + + const getSnapshotUrl = useCallback( + (forSavedObject?: boolean) => { + let tempUrl = ''; + if (forSavedObject && shareableUrlForSavedObject) { + tempUrl = shareableUrlForSavedObject; + } + if (!tempUrl) { + tempUrl = shareableUrl || window.location.href; + } + return updateUrlParams(tempUrl); + }, + [updateUrlParams, shareableUrl, shareableUrlForSavedObject] + ); + + const getSavedObjectUrl = useCallback(() => { + const tempUrl = getSnapshotUrl(true); + + const parsedUrl = parseUrl(tempUrl); + + if (!parsedUrl || !parsedUrl.hash) { + return; + } + + // Get the application route, after the hash, and remove the #. + const parsedAppUrl = parseUrl(parsedUrl.hash.slice(1), true); + + const formattedUrl = formatUrl({ + protocol: parsedUrl.protocol, + auth: parsedUrl.auth, + host: parsedUrl.host, + pathname: parsedUrl.pathname, + hash: formatUrl({ + pathname: parsedAppUrl.pathname, + query: { + // Add global state to the URL so that the iframe doesn't just show the time range + // default. + _g: parsedAppUrl.query._g, + }, + }), + }); + + return updateUrlParams(formattedUrl); + }, [getSnapshotUrl, updateUrlParams]); + + const addUrlAnonymousAccessParameters = useCallback( + (tempUrl: string): string => { + if (!anonymousAccessParameters || !usePublicUrl) { + return tempUrl; + } + + const parsedUrl = new URL(tempUrl); + + for (const [name, value] of Object.entries(anonymousAccessParameters)) { + parsedUrl.searchParams.set(name, value); + } + + return parsedUrl.toString(); + }, + [anonymousAccessParameters, usePublicUrl] + ); + + const makeIframeTag = (tempUrl: string) => { + if (!tempUrl) { + return; + } + + return ``; + }; + + const setUrlHelper = useCallback(() => { + let tempUrl: string | undefined; + + if (exportUrlAs === ExportUrlAsType.EXPORT_URL_AS_SAVED_OBJECT) { + tempUrl = getSavedObjectUrl(); + } else if (useShortUrl && shortUrlCache) { + tempUrl = shortUrlCache; + } else { + tempUrl = getSnapshotUrl(); + } + + if (tempUrl) { + tempUrl = addUrlAnonymousAccessParameters(tempUrl!); + } + + if (isEmbedded) { + tempUrl = makeIframeTag(tempUrl!); + } + setUrl(tempUrl!); + }, [ + addUrlAnonymousAccessParameters, + exportUrlAs, + getSavedObjectUrl, + getSnapshotUrl, + shortUrlCache, + useShortUrl, + isEmbedded, + ]); + + const resetUrl = useCallback(() => { + if (isMounted()) { + setShortUrlCache(undefined); + setUrlHelper(); + } + }, [isMounted, setUrlHelper]); + + useEffect(() => { + setUrlHelper(); + getUrlParamExtensions(url); + window.addEventListener('hashchange', resetUrl, false); + onChange(url); + isMounted(); + }, [getUrlParamExtensions, resetUrl, setUrlHelper, url, isMounted, onChange]); + + const renderUrlParamExtensions = () => { + if (!urlParamExtensions) { + return null; + } + + const setParamValue = + (paramName: string) => + (values: { [queryParam: string]: boolean } = {}): void => { + setUrlParams({ ...urlParams, [paramName]: { ...values } }); + setUrlHelper(); + }; + + return ( + + {urlParamExtensions.map(({ paramName, component: UrlParamComponent }) => ( + + + + ))} + + ); + }; + + return ( + + + + + + + {renderUrlParamExtensions()} + + + ); +}; diff --git a/src/plugins/share/public/components/tabs/embed/index.tsx b/src/plugins/share/public/components/tabs/embed/index.tsx new file mode 100644 index 00000000000000..ca95c1f1718d11 --- /dev/null +++ b/src/plugins/share/public/components/tabs/embed/index.tsx @@ -0,0 +1,82 @@ +/* + * 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, { useCallback } from 'react'; +import { copyToClipboard } from '@elastic/eui'; +import { type ITabDeclaration } 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', +}; + +type IEmbedTab = ITabDeclaration<{ url: string }>; + +const embedTabReducer: IEmbedTab['reducer'] = (state = { url: '' }, action) => { + switch (action.type) { + case EMBED_TAB_ACTIONS.SET_EMBED_URL: + return { + ...state, + url: action.payload, + }; + default: + return state; + } +}; + +const EmbedTabContent: NonNullable = ({ dispatch }) => { + const { embedUrlParamExtensions, shareableUrlForSavedObject, shareableUrl, isEmbedded } = + useShareTabsContext()!; + + const onChange = useCallback( + (shareUrl: string) => { + dispatch({ + type: EMBED_TAB_ACTIONS.SET_EMBED_URL, + payload: shareUrl, + }); + }, + [dispatch] + ); + + return ( + + ); +}; + +export const embedTab: IEmbedTab = { + id: 'embed', + name: i18n.translate('share.contextMenu.embedCodeTab', { + defaultMessage: 'Embed', + }), + description: i18n.translate('share.dashboard.embed.description', { + defaultMessage: + 'Embed this dashboard into another webpage. Select which menu items to include in the embeddable view.', + }), + reducer: embedTabReducer, + content: EmbedTabContent, + modalActionBtn: { + id: 'embed', + dataTestSubj: 'copyEmbedUrlButton', + label: i18n.translate('share.link.copyEmbedCodeButton', { + defaultMessage: 'Copy Embed', + }), + handler: ({ state }) => { + copyToClipboard(state.url); + }, + }, +}; diff --git a/src/plugins/share/public/components/tabs/export/export_content.tsx b/src/plugins/share/public/components/tabs/export/export_content.tsx new file mode 100644 index 00000000000000..f9f9a738e0ca77 --- /dev/null +++ b/src/plugins/share/public/components/tabs/export/export_content.tsx @@ -0,0 +1,412 @@ +/* + * 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, useMemo, useState } from 'react'; +import { FormattedMessage, InjectedIntl } from '@kbn/i18n-react'; +import type { ReportingAPIClient } from '@kbn/reporting-public'; + +import { + EuiButton, + EuiButtonEmpty, + EuiCopy, + EuiFlexGroup, + EuiForm, + EuiIcon, + EuiModalFooter, + EuiRadioGroup, + EuiSpacer, + EuiSwitch, + EuiSwitchEvent, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import useMountedState from 'react-use/lib/useMountedState'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import url from 'url'; +import { LayoutParams } from '@kbn/screenshotting-plugin/common'; +import { JobParamsProviderOptions } from '@kbn/reporting-public/share/share_context_menu'; +import { BaseParams } from '@kbn/reporting-common/types'; +import { ThemeServiceSetup, ToastsSetup } from '@kbn/core/public'; +import { type IShareContext } from '../../context'; + +type ExportProps = Pick< + IShareContext, + 'isDirty' | 'objectId' | 'objectType' | 'onClose' | 'i18n' +> & { + reportingAPIClient: ReportingAPIClient; + getJobParams: Array<{ id: string; handler: Function }>; + helpText: FormattedMessage; + generateReportButton: FormattedMessage; + jobProviderOptions?: JobParamsProviderOptions; + layoutOption?: 'print'; + toasts: ToastsSetup; + theme: ThemeServiceSetup; + downloadCSVLens: Function; + reportType: string[]; +}; + +type AllowedExports = 'pngV2' | 'printablePdfV2' | 'csv_v2' | 'csv_searchsource' | 'csv'; +type AppParams = Omit; +type Props = ExportProps & { intl: InjectedIntl }; + +export const ExportContent = ({ + getJobParams, + theme, + reportingAPIClient, + helpText, + isDirty, + objectId, + objectType, + generateReportButton, + jobProviderOptions, + layoutOption, + intl, + toasts, + onClose, + downloadCSVLens, + i18n: i18nStart, + reportType, +}: Props) => { + const csvReportTypeCheck = reportType[0]; + const isSaved = Boolean(objectId) || !isDirty; + const [, setIsStale] = useState(false); + const [isCreatingReport, setIsCreatingReport] = useState(false); + const [selectedRadio, setSelectedRadio] = useState( + csvReportTypeCheck as AllowedExports + ); + const [usePrintLayout, setPrintLayout] = useState(false); + const [absoluteUrl, setAbsoluteUrl] = useState(''); + const isMounted = useMountedState(); + + const getJobsParamsForImageExports = useCallback( + (type: AllowedExports, opts?: JobParamsProviderOptions) => { + if (!opts) { + return; + } + + const { + sharingData: { title, layout, locatorParams }, + } = opts; + + const baseParams = { + objectType, + layout, + title, + }; + + if (type === 'printablePdfV2') { + // multi locator for PDF V2 + return { ...baseParams, locatorParams: [locatorParams] }; + } else if (type === 'pngV2') { + // single locator for PNG V2 + return { ...baseParams, locatorParams }; + } + + // Relative URL must have URL prefix (Spaces ID prefix), but not server basePath + // Replace hashes with original RISON values. + const relativeUrl = opts?.shareableUrl.replace( + window.location.origin + reportingAPIClient.getServerBasePath(), + '' + ); + + // single URL for PNG + return { ...baseParams, relativeUrl }; + }, + [reportingAPIClient, objectType] + ); + + const getLayout = useCallback((): LayoutParams => { + const el = document.querySelector('[data-shared-items-container]'); + const { height, width } = el ? el.getBoundingClientRect() : { height: 768, width: 1024 }; + const dimensions = { height, width }; + + if (usePrintLayout) { + return { id: 'print', dimensions }; + } + + return { id: 'preserve_layout', dimensions }; + }, [usePrintLayout]); + + const getJobParamsImages = useCallback( + (shareableUrl?: boolean) => { + return { + ...getJobsParamsForImageExports(selectedRadio, jobProviderOptions), + layout: getLayout(), + }; + }, + [getJobsParamsForImageExports, getLayout, jobProviderOptions, selectedRadio] + ); + + const getAbsoluteReportGenerationUrl = useMemo( + () => () => { + if (getJobsParamsForImageExports(selectedRadio, jobProviderOptions) !== undefined) { + const relativePath = reportingAPIClient.getReportingPublicJobPath( + selectedRadio, + reportingAPIClient.getDecoratedJobParams(getJobParamsImages(true) as unknown as AppParams) + ); + return setAbsoluteUrl(url.resolve(window.location.href, relativePath)); + } + }, + [ + reportingAPIClient, + getJobParamsImages, + selectedRadio, + getJobsParamsForImageExports, + jobProviderOptions, + ] + ); + + const markAsStale = useCallback(() => { + if (!isMounted) return; + setIsStale(true); + }, [isMounted]); + + useEffect(() => { + getAbsoluteReportGenerationUrl(); + markAsStale(); + }, [markAsStale, getAbsoluteReportGenerationUrl]); + + const handlePrintLayoutChange = (evt: EuiSwitchEvent) => { + setPrintLayout(evt.target.checked); + }; + + const renderLayoutOptionsSwitch = () => { + if (layoutOption === ('print' as const) && selectedRadio !== 'pngV2') { + return ( + <> + + + + } + css={{ display: 'block' }} + checked={usePrintLayout} + onChange={handlePrintLayoutChange} + data-test-subj="usePrintLayout" + /> + + } + > + + + + ); + } + }; + const renderCopyURLButton = useCallback(() => { + return ( + <> + + ) : ( + + ) + } + > + + {(copy) => ( + + + + )} + + + + } + > + + + + ); + }, [absoluteUrl, isDirty]); + + const generateReportingJob = () => { + if (selectedRadio === 'csv') { + return downloadCSVLens(); + } + if (csvReportTypeCheck === 'csv_searchsource' || csvReportTypeCheck === 'csv_v2') { + setSelectedRadio(csvReportTypeCheck); + } + // // get the appropriate jobParams for either printablePdfV2 or pngV2 + // const [{handler}] = getJobParams.filter((val) => val.id === selectedRadio) + + const decoratedJobParams = reportingAPIClient.getDecoratedJobParams( + getJobParamsImages(false) as unknown as AppParams + ); + setIsCreatingReport(true); + return reportingAPIClient + .createReportingJob(selectedRadio, decoratedJobParams) + .then(() => { + toasts.addSuccess({ + title: intl.formatMessage( + { + id: 'reporting.modalContent.successfullyQueuedReportNotificationTitle', + defaultMessage: 'Queued report for {objectType}', + }, + { objectType } + ), + text: toMountPoint( + + + + ), + }} + />, + { theme, i18n: i18nStart } + ), + 'data-test-subj': 'queueReportSuccess', + }); + if (onClose) { + onClose(); + setIsCreatingReport(false); + } + if (isMounted()) { + setIsCreatingReport(false); + } + }) + .catch((error) => { + toasts.addError(error, { + title: intl!.formatMessage({ + id: 'reporting.modalContent.notification.reportingErrorTitle', + defaultMessage: 'Unable to create report', + }), + toastMessage: ( + // eslint-disable-next-line react/no-danger + + ) as unknown as string, + }); + if (isMounted()) { + setIsCreatingReport(false); + } + }); + }; + + const renderGenerateReportButton = !isSaved ? ( + + + {generateReportButton} + + + ) : ( + generateReportingJob()} + data-test-subj="generateReportButton" + isLoading={Boolean(isCreatingReport)} + > + {generateReportButton} + + ); + + const radioOptions = + objectType === 'lens' + ? [ + { id: 'printablePdfV2', label: 'PDF' }, + { id: 'pngV2', label: 'PNG', 'data-test-subj': 'pngReportOption' }, + { id: 'csv', label: 'CSV', 'data-test-subj': 'lensCSVReport' }, + ] + : [ + { id: 'printablePdfV2', label: 'PDF' }, + { id: 'pngV2', label: 'PNG', 'data-test-subj': 'pngReportOption' }, + ]; + + const renderRadioOptions = () => { + if (objectType === 'dashboard' || objectType === 'lens') { + return ( + + { + setSelectedRadio(id as AllowedExports); + getAbsoluteReportGenerationUrl(); + }} + name="image reporting radio group" + idSelected={selectedRadio} + legend={{ + children: ( + + ), + }} + /> + + ); + } + }; + + return objectType === 'lens' && !helpText ? null : ( + <> + + + {helpText} + + {renderRadioOptions()} + + + + {renderLayoutOptionsSwitch()} + {renderCopyURLButton()} + {renderGenerateReportButton} + + + ); +}; diff --git a/src/plugins/share/public/components/tabs/export/index.tsx b/src/plugins/share/public/components/tabs/export/index.tsx new file mode 100644 index 00000000000000..419f1d1932515c --- /dev/null +++ b/src/plugins/share/public/components/tabs/export/index.tsx @@ -0,0 +1,85 @@ +/* + * 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 } from '../../context'; + +type IExportTab = IModalTabDeclaration; + +const exportTabReducer: IExportTab['reducer'] = (state, action) => { + switch (action.type) { + default: + return state; + } +}; + +function ExportTabContent() { + const { + shareMenuItems, + objectType, + isDirty, + objectId, + onClose, + i18n: i18nStart, + toasts, + } = useShareTabsContext()!; + + return shareMenuItems.map((shareMenuItem, index) => { + if (objectType === 'lens' && shareMenuItem.content) { + return shareMenuItem.content; + } + + const { + getJobParams, + jobProviderOptions, + helpText, + layoutOption, + reportingAPIClient, + generateReportButton, + theme, + downloadCSVLens, + reportType, + } = shareMenuItem; + return ( + // @ts-ignore props show undefined because of v1 share design modal needed the props to be optional for congruency with Canvas + + ); + }); +} + +export const exportTab: IExportTab = { + id: 'export', + name: i18n.translate('share.contextMenu.exportCodeTab', { + defaultMessage: 'Export', + }), + reducer: exportTabReducer, + // @ts-ignore + content: ExportTabContent, +}; diff --git a/src/plugins/share/public/components/tabs/index.ts b/src/plugins/share/public/components/tabs/index.ts new file mode 100644 index 00000000000000..200cb058daba57 --- /dev/null +++ b/src/plugins/share/public/components/tabs/index.ts @@ -0,0 +1,11 @@ +/* + * 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 { linkTab } from './link'; +export { embedTab } from './embed'; +export { exportTab } from './export'; diff --git a/src/plugins/share/public/components/tabs/link/index.tsx b/src/plugins/share/public/components/tabs/link/index.tsx new file mode 100644 index 00000000000000..5ea8b15c3c8835 --- /dev/null +++ b/src/plugins/share/public/components/tabs/link/index.tsx @@ -0,0 +1,93 @@ +/* + * 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 } 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'; + +type ILinkTab = IModalTabDeclaration<{ + dashboardUrl: string; +}>; + +const LINK_TAB_ACTIONS = { + SET_DASHBOARD_URL: 'SET_DASHBOARD_URL', +}; + +const linkTabReducer: ILinkTab['reducer'] = ( + state = { + dashboardUrl: '', + }, + action +) => { + switch (action.type) { + case LINK_TAB_ACTIONS.SET_DASHBOARD_URL: + return { + ...state, + dashboardUrl: action.payload, + }; + default: + return state; + } +}; + +const LinkTabContent: ILinkTab['content'] = ({ state, dispatch }) => { + const { + objectType, + objectId, + isDirty, + isEmbedded, + shareableUrl, + shareableUrlForSavedObject, + urlService, + shareableUrlLocatorParams, + } = useShareTabsContext()!; + + const setDashboardLink = useCallback( + (url: string) => { + dispatch!({ type: LINK_TAB_ACTIONS.SET_DASHBOARD_URL, payload: url }); + }, + [dispatch] + ); + + return ( + + ); +}; + +export const linkTab: ILinkTab = { + id: 'link', + name: i18n.translate('share.contextMenu.permalinksTab', { + defaultMessage: 'Links', + }), + content: LinkTabContent, + reducer: linkTabReducer, + modalActionBtn: { + id: 'link', + dataTestSubj: 'copyShareUrlButton', + label: i18n.translate('share.link.copyLinkButton', { defaultMessage: 'Copy link' }), + handler: ({ state }) => { + copyToClipboard(state.dashboardUrl); + }, + }, +}; diff --git a/src/plugins/share/public/components/tabs/link/link_content.tsx b/src/plugins/share/public/components/tabs/link/link_content.tsx new file mode 100644 index 00000000000000..5b850a92486111 --- /dev/null +++ b/src/plugins/share/public/components/tabs/link/link_content.tsx @@ -0,0 +1,193 @@ +/* + * 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 { EuiCodeBlock, EuiForm, EuiSpacer, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useCallback, useEffect, useState } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; +import { format as formatUrl, parse as parseUrl } from 'url'; +import { IShareContext } from '../../context'; + +type LinkProps = Pick< + IShareContext, + | 'objectType' + | 'objectId' + | 'isDirty' + | 'isEmbedded' + | 'urlService' + | 'shareableUrl' + | 'shareableUrlForSavedObject' + | 'shareableUrlLocatorParams' +> & { + setDashboardLink: (url: string) => void; +}; + +interface UrlParams { + [extensionName: string]: { + [queryParam: string]: boolean; + }; +} + +export const LinkContent = ({ + objectType, + objectId, + isDirty, + isEmbedded, + shareableUrl, + shareableUrlForSavedObject, + urlService, + shareableUrlLocatorParams, + setDashboardLink, +}: LinkProps) => { + const isMounted = useMountedState(); + const [url, setUrl] = useState(''); + const [urlParams] = useState(undefined); + const [, setShortUrlCache] = useState(undefined); + + useEffect(() => { + // propagate url updates upwards to tab + setDashboardLink(url); + }, [setDashboardLink, url]); + + 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}`; + }, []); + + const getUrlParamExtensions = useCallback( + (tempUrl: string): string => { + if (!urlParams) return tempUrl; + + return Object.keys(urlParams).reduce((urlAccumulator, key) => { + const urlParam = urlParams[key]; + return urlParam + ? Object.keys(urlParam).reduce((queryAccumulator, queryParam) => { + const isQueryParamEnabled = urlParam[queryParam]; + return isQueryParamEnabled + ? queryAccumulator + `&${queryParam}=true` + : queryAccumulator; + }, urlAccumulator) + : urlAccumulator; + }, tempUrl); + }, + [urlParams] + ); + + const updateUrlParams = useCallback( + (tempUrl: string) => { + tempUrl = isEmbedded ? makeUrlEmbeddable(tempUrl) : tempUrl; + tempUrl = urlParams ? getUrlParamExtensions(tempUrl) : tempUrl; + setUrl(tempUrl); + return tempUrl; + }, + [makeUrlEmbeddable, getUrlParamExtensions, urlParams, isEmbedded] + ); + + const getSnapshotUrl = useCallback( + (forSavedObject?: boolean) => { + let tempUrl = ''; + if (forSavedObject && shareableUrlForSavedObject) { + tempUrl = shareableUrlForSavedObject; + } + if (!tempUrl) { + tempUrl = shareableUrl || window.location.href; + } + + return updateUrlParams(tempUrl); + }, + [shareableUrl, shareableUrlForSavedObject, updateUrlParams] + ); + + const getSavedObjectUrl = useCallback(() => { + if (isNotSaved()) { + return; + } + + const tempUrl = getSnapshotUrl(true); + + const parsedUrl = parseUrl(tempUrl); + if (!parsedUrl || !parsedUrl.hash) { + return; + } + + // Get the application route, after the hash, and remove the #. + const parsedAppUrl = parseUrl(parsedUrl.hash.slice(1), true); + + const formattedUrl = formatUrl({ + protocol: parsedUrl.protocol, + auth: parsedUrl.auth, + host: parsedUrl.host, + pathname: parsedUrl.pathname, + hash: formatUrl({ + pathname: parsedAppUrl.pathname, + query: { + // Add global state to the URL so that the iframe doesn't just show the time range + // default. + _g: parsedAppUrl.query._g, + }, + }), + }); + return updateUrlParams(formattedUrl); + }, [getSnapshotUrl, isNotSaved, updateUrlParams]); + + const createShortUrl = useCallback( + async (tempUrl: string) => { + if (!isMounted) 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] + ); + + const setUrlHelper = useCallback(() => { + let tempUrl: string | undefined; + + if (objectType === 'dashboard' || objectType === 'search') { + tempUrl = getSnapshotUrl(); + } else { + tempUrl = getSavedObjectUrl(); + } + return url === '' || objectType === 'lens' ? setUrl(tempUrl!) : createShortUrl(tempUrl!); + }, [getSavedObjectUrl, getSnapshotUrl, createShortUrl, objectType, url]); + + useEffect(() => { + isMounted(); + setUrlHelper(); + }, [isMounted, setUrlHelper]); + + return ( + + + + + + + + {shareableUrl ?? url} + + + + ); +}; diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index f212081133b2da..160cd6f6d7ca8e 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -32,7 +32,6 @@ export type { RedirectOptions } from '../common/url_service'; export { useLocatorUrl } from '../common/url_service/locators/use_locator_url'; import { SharePlugin } from './plugin'; - export { downloadMultipleAs, downloadFileAs } from './lib/download_as'; export type { DownloadableContent } from './lib/download_as'; diff --git a/src/plugins/share/public/mocks.ts b/src/plugins/share/public/mocks.ts index f7fd6b8aa0734a..faa45d31d277a3 100644 --- a/src/plugins/share/public/mocks.ts +++ b/src/plugins/share/public/mocks.ts @@ -42,6 +42,7 @@ const createSetupContract = (): Setup => { url, navigate: jest.fn(), setAnonymousAccessServiceProvider: jest.fn(), + kibanaVersion: '', }; return setupContract; }; diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index ed3567d411654a..73eaabe75843c2 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -32,6 +32,11 @@ export type SharePublicSetup = ShareMenuRegistrySetup & { */ url: BrowserUrlService; + /** + * this plugin exposes the kibana version for other plugins needing to pass a Reporting API client + */ + kibanaVersion: string; + /** * Accepts serialized values for extracting a locator, migrating state from a provided version against * the locator, then using the locator to navigate. @@ -42,6 +47,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 */ @@ -79,9 +89,11 @@ export class SharePlugin private redirectManager?: RedirectManager; private url?: BrowserUrlService; private anonymousAccessServiceProvider?: () => AnonymousAccessServiceContract; + private kibanaVersion: string; constructor(private readonly initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); + this.kibanaVersion = initializerContext.env.packageInfo.version; } public setup(core: CoreSetup): SharePublicSetup { @@ -127,6 +139,7 @@ export class SharePlugin return { ...this.shareMenuRegistry.setup(), + kibanaVersion: this.kibanaVersion, url: this.url, navigate: (options: RedirectOptions) => this.redirectManager!.navigate(options), setAnonymousAccessServiceProvider: (provider: () => AnonymousAccessServiceContract) => { @@ -135,6 +148,7 @@ export class SharePlugin } this.anonymousAccessServiceProvider = provider; }, + isNewVersion: this.config.new_version.enabled, }; } diff --git a/src/plugins/share/public/services/share_menu_manager.tsx b/src/plugins/share/public/services/share_menu_manager.tsx index 842b2bce1d62a8..d9e149255a8ad0 100644 --- a/src/plugins/share/public/services/share_menu_manager.tsx +++ b/src/plugins/share/public/services/share_menu_manager.tsx @@ -9,15 +9,16 @@ 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/kibana-react-plugin/public'; + +import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; import { ShareMenuItem, ShowShareMenuOptions } from '../types'; import { ShareMenuRegistryStart } from './share_menu_registry'; import { AnonymousAccessServiceContract } from '../../common/anonymous_access'; import type { BrowserUrlService } from '../types'; -import { ShareMenuTabs } from '../components/share_tabs'; +import { ShareMenuV2 } from '../components/share_tabs'; import { ShareContextMenu } from '../components/share_context_menu'; export class ShareMenuManager { @@ -57,6 +58,7 @@ export class ShareMenuManager { overlays: core.overlays, i18n: core.i18n, newVersionEnabled, + toasts: core.notifications.toasts, }); }, }; @@ -91,6 +93,7 @@ export class ShareMenuManager { i18n, isDirty, newVersionEnabled, + toasts, }: ShowShareMenuOptions & { anchorElement: HTMLElement; menuItems: ShareMenuItem[]; @@ -102,6 +105,7 @@ export class ShareMenuManager { i18n: CoreStart['i18n']; isDirty: boolean; newVersionEnabled: boolean; + toasts: ToastsSetup; }) { if (this.isOpen) { onClose(); @@ -114,7 +118,7 @@ export class ShareMenuManager { if (!newVersionEnabled) { const element = ( - + { const session = overlays.openModal( toMountPoint( - { - onClose(); - session.close(); + { + onClose(); + session.close(); + }, }} - embedUrlParamExtensions={embedUrlParamExtensions} - anonymousAccess={anonymousAccess} - showPublicUrlSwitch={showPublicUrlSwitch} - urlService={urlService} - snapshotShareWarning={snapshotShareWarning} - disabledShareUrl={disabledShareUrl} - isDirty={isDirty} - isEmbedded={allowEmbed} />, { i18n, theme } ), diff --git a/src/plugins/share/public/types.ts b/src/plugins/share/public/types.ts index 1832d0a72ed922..53ec39287d120e 100644 --- a/src/plugins/share/public/types.ts +++ b/src/plugins/share/public/types.ts @@ -6,10 +6,12 @@ * 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, IToasts, ThemeServiceSetup } from '@kbn/core/public'; +import type { JobParamsProviderOptions } from '@kbn/reporting-public/share/share_context_menu'; +import { ReportingAPIClient } from '@kbn/reporting-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'; @@ -71,8 +73,23 @@ 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; + reportType?: Array; + tabType?: string; + requresSavedState?: boolean; + helpText?: ReactElement; + copyURLButton?: { id: string; dataTestSubj: string; label: string }; + generateReportButton?: ReactElement; + getJobParams?: Array<{ id: string; handler: Function }>; + reportingAPIClient?: ReportingAPIClient; + jobProviderOptions?: JobParamsProviderOptions; + layoutOption?: 'print'; + toasts?: IToasts; + theme?: ThemeServiceSetup; + downloadCSVLens?: Function; + content?: ReactElement; } /** @@ -84,7 +101,6 @@ export interface ShareMenuItem { * */ export interface ShareMenuProvider { readonly id: string; - getShareMenuItems: (context: ShareContext) => ShareMenuItem[]; } diff --git a/src/plugins/share/tsconfig.json b/src/plugins/share/tsconfig.json index ea5f056f7862a1..49a25e75fbef05 100644 --- a/src/plugins/share/tsconfig.json +++ b/src/plugins/share/tsconfig.json @@ -6,7 +6,6 @@ "include": ["common/**/*", "public/**/*", "server/**/*"], "kbn_references": [ "@kbn/core", - "@kbn/kibana-react-plugin", "@kbn/kibana-utils-plugin", "@kbn/utility-types", "@kbn/i18n", @@ -19,8 +18,8 @@ "@kbn/shared-ux-error-boundary", "@kbn/core-chrome-browser", "@kbn/shared-ux-prompt-not-found", - "@kbn/core-capabilities-common", "@kbn/react-kibana-mount", + "@kbn/shared-ux-tabbed-modal", ], "exclude": [ "target/**/*", diff --git a/tsconfig.base.json b/tsconfig.base.json index 0f694bcce75f28..35c55d8d0da95f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1282,6 +1282,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-csv-share-panel": ["packages/kbn-reporting/get_csv_panel_actions"], + "@kbn/reporting-csv-share-panel/*": ["packages/kbn-reporting/get_csv_panel_actions/*"], "@kbn/reporting-example-plugin": ["x-pack/examples/reporting_example"], "@kbn/reporting-example-plugin/*": ["x-pack/examples/reporting_example/*"], "@kbn/reporting-export-types-csv": ["packages/kbn-reporting/export_types/csv"], @@ -1582,6 +1584,8 @@ "@kbn/shared-ux-storybook-config/*": ["packages/shared-ux/storybook/config/*"], "@kbn/shared-ux-storybook-mock": ["packages/shared-ux/storybook/mock"], "@kbn/shared-ux-storybook-mock/*": ["packages/shared-ux/storybook/mock/*"], + "@kbn/shared-ux-tabbed-modal": ["packages/shared-ux/modal/tabbed"], + "@kbn/shared-ux-tabbed-modal/*": ["packages/shared-ux/modal/tabbed/*"], "@kbn/shared-ux-utility": ["packages/kbn-shared-ux-utility"], "@kbn/shared-ux-utility/*": ["packages/kbn-shared-ux-utility/*"], "@kbn/slo-plugin": ["x-pack/plugins/observability_solution/slo"], diff --git a/x-pack/plugins/lens/kibana.jsonc b/x-pack/plugins/lens/kibana.jsonc index add1658514cd02..a480328b7ceae1 100644 --- a/x-pack/plugins/lens/kibana.jsonc +++ b/x-pack/plugins/lens/kibana.jsonc @@ -35,7 +35,8 @@ "expressionTagcloud", "eventAnnotation", "unifiedSearch", - "contentManagement" + "contentManagement", + "licensing" ], "optionalPlugins": [ "expressionLegacyMetricVis", diff --git a/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_panel_content.tsx b/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_panel_content.tsx index 59d76f78123fcc..0b877363ad55f2 100644 --- a/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_panel_content.tsx +++ b/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_panel_content.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiButton, EuiForm, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiButton, EuiForm, EuiModalFooter, EuiSpacer, EuiText } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -21,32 +21,32 @@ export function DownloadPanelContent({ warnings = [], }: DownloadPanelContentProps) { return ( - - -

+ <> + + -

- {warnings.map((warning, i) => ( -

{warning}

- ))} -
- - - - -
+ {warnings.map((warning, i) => ( +

{warning}

+ ))} + + + + + + + + + ); } diff --git a/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_provider.tsx b/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_provider.tsx index 88c7c8de2c0924..319be3ea40d37d 100644 --- a/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_provider.tsx +++ b/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_provider.tsx @@ -96,11 +96,13 @@ function getWarnings(activeData: TableInspectorAdapter) { interface DownloadPanelShareOpts { uiSettings: IUiSettingsClient; formatFactoryFn: () => FormatFactory; + atLeastGold: boolean; } export const downloadCsvShareProvider = ({ uiSettings, formatFactoryFn, + atLeastGold, }: DownloadPanelShareOpts): ShareMenuProvider => { const getShareMenuItems = ({ objectType, sharingData, onClose }: ShareContext) => { if ('lens' !== objectType) { @@ -121,36 +123,48 @@ export const downloadCsvShareProvider = ({ } ); - return [ - { - shareMenuItem: { - name: panelTitle, - icon: 'document', - disabled: !csvEnabled, - sortOrder: 1, - }, - panel: { - id: 'csvDownloadPanel', - title: panelTitle, - content: ( - { - await downloadCSVs({ - title, - formatFactory: formatFactoryFn(), - activeData, - uiSettings, - columnsSorting, - }); - onClose?.(); - }} - /> - ), - }, - }, - ]; + return atLeastGold + ? [ + { + downloadCSVLens: async () => { + await downloadCSVs({ + title, + formatFactory: formatFactoryFn(), + activeData, + uiSettings, + columnsSorting, + }); + onClose?.(); + }, + reportType: ['csv'], + }, + ] + : [ + { + shareMenuItem: { + name: panelTitle, + icon: 'document', + disabled: !csvEnabled, + sortOrder: 1, + }, + content: ( + { + await downloadCSVs({ + title, + formatFactory: formatFactoryFn(), + activeData, + uiSettings, + columnsSorting, + }); + onClose?.(); + }} + /> + ), + }, + ]; }; return { diff --git a/x-pack/plugins/lens/public/app_plugin/csv_download_provider/index.ts b/x-pack/plugins/lens/public/app_plugin/csv_download_provider/index.ts new file mode 100644 index 00000000000000..f212685f96a996 --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/csv_download_provider/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { downloadCSVs } from './csv_download_provider'; diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index 0088e434cb72d9..b188a973ca2aa1 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -121,6 +121,7 @@ export type { ChartInfo } from './chart_info_api'; export { layerTypes } from '../common/layer_types'; export { LENS_EMBEDDABLE_TYPE } from '../common/constants'; +export { downloadCSVs } from './app_plugin/csv_download_provider/csv_download_provider'; export type { LensPublicStart, LensPublicSetup, LensSuggestionsApi } from './plugin'; diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 8bd4206a3fe988..4bf133f5064dcd 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { from } 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 +65,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 +182,7 @@ export interface LensPluginStartDependencies { eventAnnotationService: EventAnnotationServiceType; contentManagement: ContentManagementPublicStart; serverless?: ServerlessPluginStart; + licensing?: LicensingPluginStart; } export interface LensPublicSetup { @@ -389,12 +393,22 @@ export class LensPlugin { if (share) { this.locator = share.url.locators.create(new LensAppLocatorDefinition()); - share.register( - downloadCsvShareProvider({ - uiSettings: core.uiSettings, - formatFactoryFn: () => startServices().plugins.fieldFormats.deserialize, - }) - ); + const { getStartServices } = core; + const startServices$ = from(getStartServices()); + startServices$.subscribe(([, { licensing }]) => { + licensing?.license$.subscribe((license) => { + const atLeastGold = license.hasAtLeast('gold'); + if (atLeastGold) { + return share.register( + downloadCsvShareProvider({ + uiSettings: core.uiSettings, + formatFactoryFn: () => startServices().plugins.fieldFormats.deserialize, + atLeastGold, + }) + ); + } + }); + }); } visualizations.registerAlias(getLensAliasConfig()); diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json index f0b7038d05ab76..99806418a2605e 100644 --- a/x-pack/plugins/lens/tsconfig.json +++ b/x-pack/plugins/lens/tsconfig.json @@ -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/**/*"] } diff --git a/x-pack/plugins/reporting/kibana.jsonc b/x-pack/plugins/reporting/kibana.jsonc index be398a1d868d93..bd0f44aad4a81f 100644 --- a/x-pack/plugins/reporting/kibana.jsonc +++ b/x-pack/plugins/reporting/kibana.jsonc @@ -22,7 +22,8 @@ "taskManager", "screenshotMode", "share", - "features" + "features", + "lens" ], "optionalPlugins": [ "security", diff --git a/x-pack/plugins/reporting/public/plugin.ts b/x-pack/plugins/reporting/public/plugin.ts index f7dfbc0999f342..b5d5ddc4046615 100644 --- a/x-pack/plugins/reporting/public/plugin.ts +++ b/x-pack/plugins/reporting/public/plugin.ts @@ -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, + reportingExportModalProvider, reportingScreenshotShareProvider, } from '@kbn/reporting-public/share'; +import { ReportingCsvPanelAction } from '@kbn/reporting-csv-share-panel'; +import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { ReportingSetup, ReportingStart } from '.'; import { ReportingNotifierStreamHandler as StreamHandler } from './lib/stream_handler'; @@ -53,6 +55,8 @@ export interface ReportingPublicPluginStartDependencies { licensing: LicensingPluginStart; uiActions: UiActionsStart; share: SharePluginStart; + // needed for lens csv + fieldFormats: FieldFormatsStart; } /** @@ -70,7 +74,7 @@ export class ReportingPublicPlugin { private kibanaVersion: string; private apiClient?: ReportingAPIClient; - private readonly stop$ = new Rx.ReplaySubject(1); + private readonly stop$ = new ReplaySubject(1); private readonly title = i18n.translate('xpack.reporting.management.reportingTitle', { defaultMessage: 'Reporting', }); @@ -123,7 +127,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 +209,7 @@ export class ReportingPublicPlugin const reportingStart = this.getContract(core); const { toasts } = core.notifications; - startServices$.subscribe(([{ application }, { licensing }]) => { + startServices$.subscribe(([{ application }, { licensing, fieldFormats }]) => { licensing.license$.subscribe((license) => { shareSetup.register( reportingCsvShareProvider({ @@ -220,6 +224,18 @@ export class ReportingPublicPlugin ); if (this.config.export_types.pdf.enabled || this.config.export_types.png.enabled) { + shareSetup.register( + reportingExportModalProvider({ + apiClient, + toasts, + uiSettings, + license, + application, + usesUiCapabilities, + theme: core.theme, + }) + ); + shareSetup.register( reportingScreenshotShareProvider({ apiClient, @@ -234,7 +250,6 @@ export class ReportingPublicPlugin } }); }); - return reportingStart; } diff --git a/x-pack/plugins/reporting/tsconfig.json b/x-pack/plugins/reporting/tsconfig.json index c5a5e32008ae09..867233ad463b0c 100644 --- a/x-pack/plugins/reporting/tsconfig.json +++ b/x-pack/plugins/reporting/tsconfig.json @@ -49,6 +49,7 @@ "@kbn/core-http-request-handler-context-server", "@kbn/reporting-public", "@kbn/analytics-client", + "@kbn/reporting-csv-share-panel", ], "exclude": [ "target/**/*", diff --git a/yarn.lock b/yarn.lock index 830c926daa92fb..10c73966d3cb73 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5610,6 +5610,10 @@ version "0.0.0" uid "" +"@kbn/reporting-csv-share-panel@link:packages/kbn-reporting/get_csv_panel_actions": + version "0.0.0" + uid "" + "@kbn/reporting-example-plugin@link:x-pack/examples/reporting_example": version "0.0.0" uid "" @@ -6210,6 +6214,10 @@ version "0.0.0" uid "" +"@kbn/shared-ux-tabbed-modal@link:packages/shared-ux/modal/tabbed": + version "0.0.0" + uid "" + "@kbn/shared-ux-utility@link:packages/kbn-shared-ux-utility": version "0.0.0" uid ""