Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(() => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ export const getShareAppMenuItem = ({
discoverParams,
services,
stateContainer,
hasIntegrations,
}: {
discoverParams: AppMenuDiscoverParams;
services: DiscoverServices;
stateContainer: DiscoverStateContainer;
hasIntegrations: boolean;
}): AppMenuActionPrimary[] => {
if (!services.share) {
return [];
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -54,6 +55,7 @@ export const useDiscoverTopNav = ({
inspector: services.inspector,
stateContainer,
});
const hasShareIntegration = useHasShareIntegration(services);

const topNavMenu = useTopNavLinks({
dataView,
Expand All @@ -64,6 +66,7 @@ export const useDiscoverTopNav = ({
adHocDataViews,
topNavCustomization,
shouldShowESQLToDataViewTransitionModal,
hasShareIntegration,
});

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const useTopNavLinks = ({
adHocDataViews,
topNavCustomization,
shouldShowESQLToDataViewTransitionModal,
hasShareIntegration,
}: {
dataView: DataView | undefined;
services: DiscoverServices;
Expand All @@ -68,6 +69,7 @@ export const useTopNavLinks = ({
adHocDataViews: DataView[];
topNavCustomization: TopNavCustomization | undefined;
shouldShowESQLToDataViewTransitionModal: boolean;
hasShareIntegration: boolean;
}): TopNavMenuData[] => {
const dispatch = useInternalStateDispatch();
const currentDataView = useCurrentDataView();
Expand Down Expand Up @@ -158,6 +160,7 @@ export const useTopNavLinks = ({
discoverParams,
services,
stateContainer: state,
hasIntegrations: hasShareIntegration,
});
items.push(...shareAppMenuItem);
}
Expand All @@ -171,6 +174,7 @@ export const useTopNavLinks = ({
state,
isEsqlMode,
currentDataView,
hasShareIntegration,
]);

const getAppMenuAccessor = useProfileAccessor('getAppMenu');
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const [hasShareIntegration, setHasShareIntegration] = useState<boolean>(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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,10 +38,9 @@ export class ShareRegistry implements ShareRegistryPublicApi {
this.registerLinkShareAction();
this.registerEmbedShareAction();
}

private readonly shareOptionsStore: Record<
string,
Map<InternalShareActionIntent | `integration-${string}` | 'legacy', ShareActionIntents>
Map<ShareContextMapKey, () => Promise<ShareActionIntents>>
> = {
[this.globalMarker]: new Map(),
};
Expand Down Expand Up @@ -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<ShareActionIntents>
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the getShareActionIntent returns a promise, we can't introspect the shareActionIntent to generate a key. So we must specify a key in the parameters.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it might be better for this API we were to actually accept the groupId? for the integration and an id, so we can still manage the key for the integration internally based off these values that are meaningful to the consumer.

Copy link
Copy Markdown
Contributor

@eokoneyo eokoneyo Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another advantage to this approach would mean that we'll be able to check available integrations synchronously rather than async, and not have to modify functions returning nav config for other apps becoming async, with the resolution of the share implementation still async.

): Promise<void> {
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<ShareMenuProviderLegacy>) {
this.registerShareIntentAction(this.globalMarker, 'legacy', async () => {
const resolvedValue = await Promise.resolve(value);
return {
shareType: 'legacy',
id: resolvedValue.id,
config: resolvedValue.getShareMenuItemsLegacy,
};
});
}

private registerShareIntegration<I extends ShareIntegration>(
...args: [string, Omit<I, 'shareType'>] | [Omit<I, 'shareType'>]
shareObject: string,
key: ShareIntegrationMapKey,
getShareActionIntent: () => Promise<Omit<I, 'shareType'>>
Comment on lines +130 to +132
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This forces users to specify a share object, we should at the very least specify that shareObject is either * | string

): 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<ShareActionIntents[]> {
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<ShareActionIntents[]> {
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' ||
Expand All @@ -179,16 +185,18 @@ export class ShareRegistry implements ShareRegistryPublicApi {
});
}

private resolveShareItemsForShareContext({
private async resolveShareItemsForShareContext({
Copy link
Copy Markdown
Contributor

@eokoneyo eokoneyo Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this signature changed we'll need to in turn change the signature of toggleShareContextMenu to become async, because this method gets invoked within it. See 6a0319a

objectType,
isServerless,
...shareContext
}: ShareContext & { isServerless: boolean }): ShareConfigs[] {
}: ShareContext & { isServerless: boolean }): Promise<ShareConfigs[]> {
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;

Expand Down
14 changes: 9 additions & 5 deletions src/platform/plugins/shared/share/public/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,15 @@ export interface SharingData {
};
}

interface ShareRegistryInternalApi {
registerShareIntegration<I extends ShareIntegration>(shareObject: string, arg: I): void;
registerShareIntegration<I extends ShareIntegration>(arg: I): void;

resolveShareItemsForShareContext(args: ShareContext): ShareConfigs[];
export type ShareIntegrationMapKey = `integration-${string}`;
export interface ShareRegistryInternalApi {
registerShareIntegration<I extends ShareIntegration>(
shareObject: string,
key: ShareIntegrationMapKey,
getShareActionIntent: () => Promise<I>
): void;

resolveShareItemsForShareContext(args: ShareContext): Promise<ShareConfigs[]>;
}

export abstract class ShareRegistryPublicApi {
Expand Down
Loading