From 1e4c7f7cf62608220f1b8073f971e1495bc365bb Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Mon, 16 Jun 2025 16:23:51 -0400 Subject: [PATCH] Async share registry Currently not wired up to internal share contexts --- .../top_nav/use_dashboard_menu_items.tsx | 20 ++++- .../top_nav/app_menu_actions/get_share.tsx | 4 +- .../components/top_nav/use_discover_topnav.ts | 3 + .../components/top_nav/use_top_nav_links.tsx | 4 + .../main/hooks/use_has_share_integration.ts | 34 +++++++ .../public/services/share_menu_registry.ts | 88 ++++++++++--------- .../plugins/shared/share/public/types.ts | 14 +-- .../private/reporting/public/plugin.ts | 51 ++++++----- .../lens/public/app_plugin/lens_top_nav.tsx | 30 +++++-- .../plugins/shared/lens/public/plugin.ts | 33 ++++--- 10 files changed, 190 insertions(+), 91 deletions(-) create mode 100644 src/platform/plugins/shared/discover/public/application/main/hooks/use_has_share_integration.ts diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx index 52d132b8d5582..781224799fe53 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react'; +import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'; import type { TopNavMenuData } from '@kbn/navigation-plugin/public'; import useMountedState from 'react-use/lib/useMountedState'; @@ -259,10 +259,22 @@ export const useDashboardMenuItems = ({ */ const isLabsEnabled = useMemo(() => coreServices.uiSettings.get(UI_SETTINGS.ENABLE_LABS_UI), []); - const hasExportIntegration = Boolean( - shareService?.availableIntegrations('dashboard', 'export')?.length - ); + const [hasExportIntegration, setHasExportIntegration] = useState(false); + useEffect(() => { + let canceled = false; + const checkExportIntegration = async () => { + if (shareService) { + const integrations = await shareService.availableIntegrations('dashboard', 'export'); + if (canceled) return; + setHasExportIntegration(integrations.length > 0); + } + }; + checkExportIntegration(); + return () => { + canceled = true; + }; + }, []); const viewModeTopNavConfig = useMemo(() => { const { showWriteControls } = getDashboardCapabilities(); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx index d6f633eb9d079..ae029a0f5b1c4 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx @@ -24,10 +24,12 @@ export const getShareAppMenuItem = ({ discoverParams, services, stateContainer, + hasIntegrations, }: { discoverParams: AppMenuDiscoverParams; services: DiscoverServices; stateContainer: DiscoverStateContainer; + hasIntegrations: boolean; }): AppMenuActionPrimary[] => { if (!services.share) { return []; @@ -170,7 +172,7 @@ export const getShareAppMenuItem = ({ }, ]; - if (Boolean(services.share?.availableIntegrations('search', 'export')?.length)) { + if (hasIntegrations) { menuItems.unshift({ id: AppMenuActionId.export, type: AppMenuActionType.primary, diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts index 68dfee7b655fa..6c6aeb33490e3 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts @@ -21,6 +21,7 @@ import type { DiscoverStateContainer } from '../../state_management/discover_sta import { getTopNavBadges } from './get_top_nav_badges'; import { useTopNavLinks } from './use_top_nav_links'; import { useAdHocDataViews, useCurrentDataView } from '../../state_management/redux'; +import { useHasShareIntegration } from '../../hooks/use_has_share_integration'; export const useDiscoverTopNav = ({ stateContainer, @@ -54,6 +55,7 @@ export const useDiscoverTopNav = ({ inspector: services.inspector, stateContainer, }); + const hasShareIntegration = useHasShareIntegration(services); const topNavMenu = useTopNavLinks({ dataView, @@ -64,6 +66,7 @@ export const useDiscoverTopNav = ({ adHocDataViews, topNavCustomization, shouldShowESQLToDataViewTransitionModal, + hasShareIntegration, }); return { diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index 95ab3aa157e34..391c584985af3 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -59,6 +59,7 @@ export const useTopNavLinks = ({ adHocDataViews, topNavCustomization, shouldShowESQLToDataViewTransitionModal, + hasShareIntegration, }: { dataView: DataView | undefined; services: DiscoverServices; @@ -68,6 +69,7 @@ export const useTopNavLinks = ({ adHocDataViews: DataView[]; topNavCustomization: TopNavCustomization | undefined; shouldShowESQLToDataViewTransitionModal: boolean; + hasShareIntegration: boolean; }): TopNavMenuData[] => { const dispatch = useInternalStateDispatch(); const currentDataView = useCurrentDataView(); @@ -158,6 +160,7 @@ export const useTopNavLinks = ({ discoverParams, services, stateContainer: state, + hasIntegrations: hasShareIntegration, }); items.push(...shareAppMenuItem); } @@ -171,6 +174,7 @@ export const useTopNavLinks = ({ state, isEsqlMode, currentDataView, + hasShareIntegration, ]); const getAppMenuAccessor = useProfileAccessor('getAppMenu'); diff --git a/src/platform/plugins/shared/discover/public/application/main/hooks/use_has_share_integration.ts b/src/platform/plugins/shared/discover/public/application/main/hooks/use_has_share_integration.ts new file mode 100644 index 0000000000000..dad80d12c8160 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/hooks/use_has_share_integration.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useEffect, useState } from 'react'; +import type { DiscoverServices } from '../../../build_services'; + +export function useHasShareIntegration({ share }: DiscoverServices) { + const [hasShareIntegration, setHasShareIntegration] = useState(false); + + useEffect(() => { + let canceled = false; + if (!share) return; + const checkShareIntegration = async () => { + const integrations = await share.availableIntegrations('search', 'export'); + if (!canceled) { + setHasShareIntegration(integrations.length > 0); + } + }; + + checkShareIntegration(); + + return () => { + canceled = true; + }; + }, [share]); + + return hasShareIntegration; +} diff --git a/src/platform/plugins/shared/share/public/services/share_menu_registry.ts b/src/platform/plugins/shared/share/public/services/share_menu_registry.ts index e9d3b57a7046b..9afa4349a9ac2 100644 --- a/src/platform/plugins/shared/share/public/services/share_menu_registry.ts +++ b/src/platform/plugins/shared/share/public/services/share_menu_registry.ts @@ -19,9 +19,12 @@ import type { ShareIntegration, ShareRegistryApiStart, ShareMenuProviderLegacy, + ShareIntegrationMapKey, } from '../types'; import type { AnonymousAccessServiceContract } from '../../common/anonymous_access'; +type ShareContextMapKey = InternalShareActionIntent | ShareIntegrationMapKey | 'legacy'; + export class ShareRegistry implements ShareRegistryPublicApi { private urlService?: BrowserUrlService; private anonymousAccessServiceProvider?: () => AnonymousAccessServiceContract; @@ -35,10 +38,9 @@ export class ShareRegistry implements ShareRegistryPublicApi { this.registerLinkShareAction(); this.registerEmbedShareAction(); } - private readonly shareOptionsStore: Record< string, - Map + Map Promise> > = { [this.globalMarker]: new Map(), }; @@ -70,94 +72,98 @@ export class ShareRegistry implements ShareRegistryPublicApi { }; } - private registerShareIntentAction( + // Async registration for share actions + private async registerShareIntentAction( shareObject: string, - shareActionIntent: ShareActionIntents - ): void { + key: ShareContextMapKey, + getShareActionIntent: () => Promise + ): Promise { if (!this.shareOptionsStore[shareObject]) { this.shareOptionsStore[shareObject] = new Map(); } const shareContextMap = this.shareOptionsStore[shareObject]; - const recordKey = - shareActionIntent.shareType === 'integration' - ? (`integration-${shareActionIntent.groupId || 'unknown'}-${shareActionIntent.id}` as const) - : shareActionIntent.shareType; - - if (shareContextMap.has(recordKey)) { + if (shareContextMap.has(key)) { throw new Error( - `Share action with type [${shareActionIntent.shareType}] for app [${shareObject}] has already been registered.` + `Share action with key [${key}] for app [${shareObject}] has already been registered.` ); } - shareContextMap.set(recordKey, shareActionIntent); + shareContextMap.set(key, getShareActionIntent); } private registerLinkShareAction(): void { - this.registerShareIntentAction(this.globalMarker, { + this.registerShareIntentAction(this.globalMarker, 'link', async () => ({ shareType: 'link', config: ({ urlService }) => ({ shortUrlService: urlService?.shortUrls.get(null)!, }), - }); + })); } private registerEmbedShareAction(): void { - this.registerShareIntentAction(this.globalMarker, { + this.registerShareIntentAction(this.globalMarker, 'embed', async () => ({ shareType: 'embed', config: ({ urlService, anonymousAccessServiceProvider }) => ({ anonymousAccess: anonymousAccessServiceProvider!(), shortUrlService: urlService.shortUrls.get(null), }), - }); + })); } /** * @description provides an escape hatch to support allowing legacy share menu items to be registered */ - private register(value: ShareMenuProviderLegacy) { - // implement backwards compatibility for the share plugin - this.registerShareIntentAction(this.globalMarker, { - shareType: 'legacy', - id: value.id, - config: value.getShareMenuItemsLegacy, + private register(value: ShareMenuProviderLegacy | Promise) { + this.registerShareIntentAction(this.globalMarker, 'legacy', async () => { + const resolvedValue = await Promise.resolve(value); + return { + shareType: 'legacy', + id: resolvedValue.id, + config: resolvedValue.getShareMenuItemsLegacy, + }; }); } private registerShareIntegration( - ...args: [string, Omit] | [Omit] + shareObject: string, + key: ShareIntegrationMapKey, + getShareActionIntent: () => Promise> ): void { - const [shareObject, shareActionIntent] = - args.length === 1 ? [this.globalMarker, args[0]] : args; - this.registerShareIntentAction(shareObject, { + this.registerShareIntentAction(shareObject, key, async () => ({ shareType: 'integration', - ...shareActionIntent, - }); + ...(await getShareActionIntent()), + })); } - private getShareConfigOptionsForObject( + private async getShareConfigOptionsForObject( objectType: ShareContext['objectType'] - ): ShareActionIntents[] { + ): Promise { const shareContextMap = this.shareOptionsStore[objectType]; const globalOptions = Array.from(this.shareOptionsStore[this.globalMarker].values()); - if (!shareContextMap) { - return globalOptions; - } + const allFactories = shareContextMap + ? [...globalOptions, ...Array.from(shareContextMap.values())] + : globalOptions; - return globalOptions.concat(Array.from(shareContextMap.values())); + return Promise.all(allFactories.map((factory) => factory())); } /** * Returns all share actions that are available for the given object type. */ - private availableIntegrations(objectType: string, groupId?: string): ShareActionIntents[] { + private async availableIntegrations( + objectType: string, + groupId?: string + ): Promise { if (!this.capabilities || !this.getLicense) { throw new Error('ShareOptionsManager#start was not invoked'); } - return this.getShareConfigOptionsForObject(objectType).filter((share) => { + const shareActions = await this.getShareConfigOptionsForObject(objectType); + + return shareActions.filter((share) => { if ( groupId && (share.shareType !== 'integration' || @@ -179,16 +185,18 @@ export class ShareRegistry implements ShareRegistryPublicApi { }); } - private resolveShareItemsForShareContext({ + private async resolveShareItemsForShareContext({ objectType, isServerless, ...shareContext - }: ShareContext & { isServerless: boolean }): ShareConfigs[] { + }: ShareContext & { isServerless: boolean }): Promise { if (!this.urlService || !this.anonymousAccessServiceProvider) { throw new Error('ShareOptionsManager#start was not invoked'); } - return this.availableIntegrations(objectType) + const shareActions = await this.availableIntegrations(objectType); + + return shareActions .map((shareAction) => { let config: ShareConfigs['config'] | null; diff --git a/src/platform/plugins/shared/share/public/types.ts b/src/platform/plugins/shared/share/public/types.ts index 1f5c5558177c8..5ec1b7de72d30 100644 --- a/src/platform/plugins/shared/share/public/types.ts +++ b/src/platform/plugins/shared/share/public/types.ts @@ -225,11 +225,15 @@ export interface SharingData { }; } -interface ShareRegistryInternalApi { - registerShareIntegration(shareObject: string, arg: I): void; - registerShareIntegration(arg: I): void; - - resolveShareItemsForShareContext(args: ShareContext): ShareConfigs[]; +export type ShareIntegrationMapKey = `integration-${string}`; +export interface ShareRegistryInternalApi { + registerShareIntegration( + shareObject: string, + key: ShareIntegrationMapKey, + getShareActionIntent: () => Promise + ): void; + + resolveShareItemsForShareContext(args: ShareContext): Promise; } export abstract class ShareRegistryPublicApi { diff --git a/x-pack/platform/plugins/private/reporting/public/plugin.ts b/x-pack/platform/plugins/private/reporting/public/plugin.ts index 4ba306e084302..a70fff0719911 100644 --- a/x-pack/platform/plugins/private/reporting/public/plugin.ts +++ b/x-pack/platform/plugins/private/reporting/public/plugin.ts @@ -22,12 +22,7 @@ import { durationToNumber } from '@kbn/reporting-common'; import type { ClientConfigType } from '@kbn/reporting-public'; import { ReportingAPIClient } from '@kbn/reporting-public'; -import { - getSharedComponents, - reportingCsvExportProvider, - reportingPDFExportProvider, - reportingPNGExportProvider, -} from '@kbn/reporting-public/share'; +import { getSharedComponents } from '@kbn/reporting-public/share/shared'; import { ReportingCsvPanelAction } from '@kbn/reporting-csv-share-panel'; import { InjectedIntl } from '@kbn/i18n-react'; import type { ReportingSetup, ReportingStart } from '.'; @@ -209,28 +204,42 @@ export class ReportingPublicPlugin shareSetup.registerShareIntegration( 'search', - // TODO: export the reporting pdf export provider for registration in the actual plugins that depend on it - reportingCsvExportProvider({ - apiClient, - startServices$, - }) + 'integration-export-csvReports', + async () => { + // TODO: export the reporting pdf export provider for registration in the actual plugins that depend on it + const { reportingCsvExportProvider } = await import('@kbn/reporting-public/share'); + return reportingCsvExportProvider({ + apiClient, + startServices$, + }); + } ); if (this.config.export_types.pdf.enabled || this.config.export_types.png.enabled) { shareSetup.registerShareIntegration( - // TODO: export the reporting pdf export provider for registration in the actual plugins that depend on it - reportingPDFExportProvider({ - apiClient, - startServices$, - }) + 'search', + 'integration-export-pdfReports', + async () => { + // TODO: export the reporting pdf export provider for registration in the actual plugins that depend on it + const { reportingPDFExportProvider } = await import('@kbn/reporting-public/share'); + return reportingPDFExportProvider({ + apiClient, + startServices$, + }); + } ); shareSetup.registerShareIntegration( - // TODO: export the reporting pdf export provider for registration in the actual plugins that depend on it - reportingPNGExportProvider({ - apiClient, - startServices$, - }) + 'search', + 'integration-export-imageReports', + async () => { + // TODO: export the reporting pdf export provider for registration in the actual plugins that depend on it + const { reportingPNGExportProvider } = await import('@kbn/reporting-public/share'); + return reportingPNGExportProvider({ + apiClient, + startServices$, + }); + } ); } diff --git a/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_top_nav.tsx index 7ba4f1ad6732e..bc21984fbd768 100644 --- a/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_top_nav.tsx @@ -483,12 +483,29 @@ export const LensTopNavMenu = ({ isOnTextBasedMode, ]); + const [hasShareIntegration, setHasShareIntegration] = useState(false); + useEffect(() => { + let canceled = false; + if (!share) return; + const checkShareIntegration = async () => { + if (canceled) { + return; + } + const integrations = await share.availableIntegrations('lens', 'export'); + if (canceled) return; + + setHasShareIntegration(integrations.length > 0); + }; + + checkShareIntegration(); return () => { + canceled = true; // Make sure to close the editors when unmounting closeFieldEditor.current?.(); closeDataViewEditor.current?.(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const { AggregateQueryTopNavMenu } = navigation.ui; @@ -785,7 +802,7 @@ export const LensTopNavMenu = ({ inspect: { visible: true, execute: () => lensInspector.inspect({ title }) }, export: { // Only show the export button if the current user meets the requirements for at least one registered export integration - visible: Boolean(share?.availableIntegrations('lens', 'export')?.length), + visible: hasShareIntegration, enabled: showShareMenu, tooltip: () => { if (!showShareMenu) { @@ -917,7 +934,7 @@ export const LensTopNavMenu = ({ return [...(additionalMenuEntries || []), ...baseMenuEntries]; }, [ initialContext, - initialInput, + initialInput?.savedObjectId, isLinkedToOriginatingApp, initialContextIsEmbedded, activeData, @@ -927,15 +944,12 @@ export const LensTopNavMenu = ({ savingToLibraryPermitted, savingToDashboardPermitted, contextOriginatingApp, + hasShareIntegration, layerMetaInfo, additionalMenuEntries, - lensInspector, - title, share, visualization, visualizationMap, - shortUrlService, - data, filters, query, activeDatasourceId, @@ -943,9 +957,13 @@ export const LensTopNavMenu = ({ datasourceMap, currentDoc, adHocDataViews, + data, isCurrentStateDirty, dataViews.indexPatterns, + title, defaultLensTitle, + shortUrlService, + lensInspector, onAppLeave, runSave, setIsSaveModalVisible, diff --git a/x-pack/platform/plugins/shared/lens/public/plugin.ts b/x-pack/platform/plugins/shared/lens/public/plugin.ts index 4310ff7e9296d..6b56849f3c4b4 100644 --- a/x-pack/platform/plugins/shared/lens/public/plugin.ts +++ b/x-pack/platform/plugins/shared/lens/public/plugin.ts @@ -135,7 +135,6 @@ import { setupExpressions } from './expressions'; import { OpenInDiscoverDrilldown } from './trigger_actions/open_in_discover_drilldown'; import { ChartInfoApi } from './chart_info_api'; import { type LensAppLocator, LensAppLocatorDefinition } from '../common/locator/locator'; -import { downloadCsvLensShareProvider } from './app_plugin/csv_download_provider/csv_download_provider'; import { LensDocument } from './persistence/saved_object_store'; import { CONTENT_ID, @@ -428,19 +427,25 @@ export class LensPlugin { share.registerShareIntegration( 'lens', - downloadCsvLensShareProvider({ - uiSettings: core.uiSettings, - formatFactoryFn: () => startServices().plugins.fieldFormats.deserialize, - atLeastGold: () => { - let isGold = false; - startServices() - .plugins.licensing?.license$.pipe() - .subscribe((license) => { - isGold = license.hasAtLeast('gold'); - }); - return isGold; - }, - }) + 'integration-export-csvDownloadLens', + async () => { + const { downloadCsvLensShareProvider } = await import( + './app_plugin/csv_download_provider/csv_download_provider' + ); + return downloadCsvLensShareProvider({ + uiSettings: core.uiSettings, + formatFactoryFn: () => startServices().plugins.fieldFormats.deserialize, + atLeastGold: () => { + let isGold = false; + startServices() + .plugins.licensing?.license$.pipe() + .subscribe((license) => { + isGold = license.hasAtLeast('gold'); + }); + return isGold; + }, + }); + } ); }