diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx index ec0bf5f6a080..e58816393a40 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx @@ -14,6 +14,7 @@ import { Editor, ReactRenderer } from '@tiptap/react'; import { isEmpty, isNil } from 'lodash'; import { forwardRef, useImperativeHandle, useState } from 'react'; import tippy, { Instance, Props } from 'tippy.js'; +import { inCurrentAppContext } from '../../utils/RouterUtils'; import { EditorSlotsProps, EditorSlotsRef } from './BlockEditor.interface'; import BlockMenu from './BlockMenu/BlockMenu'; import BubbleMenu from './BubbleMenu/BubbleMenu'; @@ -90,7 +91,7 @@ const EditorSlots = forwardRef( const href = target.getAttribute('href'); const linkTarget = target.getAttribute('target'); if (href && linkTarget) { - window.open(href, linkTarget); + window.open(inCurrentAppContext(href), linkTarget); } return; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/hooks/useOntologyExplorer.ts b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/hooks/useOntologyExplorer.ts index aceee9607d9a..a53a7310c2c1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/hooks/useOntologyExplorer.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/hooks/useOntologyExplorer.ts @@ -40,6 +40,7 @@ import { import { getEntityDetailsPath, getGlossaryTermDetailsPath, + inCurrentAppContext, } from '../../../utils/RouterUtils'; import { getTermQuery } from '../../../utils/SearchUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; @@ -1467,7 +1468,7 @@ export function useOntologyExplorer({ if (!path) { return; } - window.open(path, '_blank'); + window.open(inCurrentAppContext(path), '_blank'); }, [getNodePath] ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/GlobalSettingPage/GlobalSettingCategory/GlobalSettingCategoryPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/GlobalSettingPage/GlobalSettingCategory/GlobalSettingCategoryPage.tsx index 8093d290b1d6..61773fecc0cf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/GlobalSettingPage/GlobalSettingCategory/GlobalSettingCategoryPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/GlobalSettingPage/GlobalSettingCategory/GlobalSettingCategoryPage.tsx @@ -79,45 +79,47 @@ const GlobalSettingCategoryPage = () => { return categoryItem; }, [settingCategory, permissions, isAdminUser]); - const handleSettingItemClick = useCallback((key: string) => { - const [category, option] = key.split('.'); - - switch (option) { - case GlobalSettingOptions.TEAMS: - navigate(getTeamsWithFqnPath(TeamType.Organization)); - - break; - case GlobalSettingOptions.ONLINE_USERS: - navigate(getSettingPath(category, option)); - - break; - case GlobalSettingOptions.SEARCH: - if (category === GlobalSettingsMenuCategory.PREFERENCES) { - navigate( - getSettingsPathWithFqn( - category, - option, - ELASTIC_SEARCH_RE_INDEX_PAGE_TABS.ON_DEMAND - ) - ); - } else { - navigate(getSettingPath(category, option)); - } - - break; - default: - if ( - connectionsRouterClassBase.isEmbeddedMode() && - category === GlobalSettingsMenuCategory.SERVICES - ) { - navigate(connectionsRouterClassBase.getSettingsServicesPath(option)); - } else { + const handleSettingItemClick = useCallback( + (key: string) => { + const [category, option] = key.split('.'); + + switch (option) { + case GlobalSettingOptions.TEAMS: + navigate(getTeamsWithFqnPath(TeamType.Organization)); + + break; + case GlobalSettingOptions.ONLINE_USERS: navigate(getSettingPath(category, option)); - } - break; - } - }, []); + break; + case GlobalSettingOptions.SEARCH: + if (category === GlobalSettingsMenuCategory.PREFERENCES) { + navigate( + getSettingsPathWithFqn( + category, + option, + ELASTIC_SEARCH_RE_INDEX_PAGE_TABS.ON_DEMAND + ) + ); + } else { + navigate(getSettingPath(category, option)); + } + + break; + default: + if (isEmbedded && category === GlobalSettingsMenuCategory.SERVICES) { + navigate( + connectionsRouterClassBase.getSettingsServicesPath(option) + ); + } else { + navigate(getSettingPath(category, option)); + } + + break; + } + }, + [isEmbedded, navigate] + ); return ( diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts index 7ba8d01c7d2a..529b3a472fd4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts @@ -21,6 +21,7 @@ import { setEditorContent, transformImgTagsToFileAttachment, } from './BlockEditorUtils'; +import { registerAppContextProvider } from './RouterUtils'; describe('getTextFromHtmlString', () => { it('should return empty string when input is undefined', () => { @@ -162,6 +163,41 @@ describe('formatContent', () => { '

This <#E::team::Infrastructure|[@Infrastructure](http://localhost:3000/settings/members/teams/Infrastructure)> team

' ); }); + + describe('mention/hashtag href storage', () => { + afterEach(() => { + registerAppContextProvider(null); + }); + + // Prefixing happens at click time in EditorSlots.tsx so stored content + // stays canonical and portable across embedding contexts. Hrefs in the + // generated HTML must not be rewritten regardless of whether a provider + // is registered. + it('stores canonical hrefs even when a provider is registered', () => { + registerAppContextProvider((u) => `/host${u}`); + + const input = + 'hi [@Infrastructure](http://localhost:3000/settings/members/teams/Infrastructure)'; + + const result = formatContent(input, 'client'); + + expect(result).toContain( + 'href="http://localhost:3000/settings/members/teams/Infrastructure"' + ); + expect(result).not.toContain('/host'); + }); + + it('stores canonical hrefs when no provider is registered', () => { + const input = + 'hi [@Infrastructure](http://localhost:3000/settings/members/teams/Infrastructure)'; + + const result = formatContent(input, 'client'); + + expect(result).toContain( + 'href="http://localhost:3000/settings/members/teams/Infrastructure"' + ); + }); + }); }); describe('isHTMLString', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts index a49c3c02c1d7..8d8c026f8d5f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts @@ -60,6 +60,10 @@ const _convertMarkdownFormatToHtmlString = (markdown: string) => { hashTagList.map((hashTag) => [hashTag, getEntityDetail(hashTag)]) ); + // hrefs are stored unprefixed; EditorSlots.tsx applies `inCurrentAppContext` + // at click time. Baking the prefix in here would (a) compound when the click + // handler re-applies it for non-idempotent providers, and (b) persist a + // host-specific prefix into stored content (descriptions, feed comments). mentionMap.forEach((value, key) => { if (value) { const [, href, rawEntityType, fqn] = value; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ConnectionsRouterClassBase.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ConnectionsRouterClassBase.test.ts index d669402f3628..1395ce2f1d09 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ConnectionsRouterClassBase.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ConnectionsRouterClassBase.test.ts @@ -59,18 +59,6 @@ describe('ConnectionsRouterClassBase', () => { router = new ConnectionsRouterClassBase(); }); - describe('embeddedMode', () => { - it('setEmbeddedMode should be a no-op', () => { - router.setEmbeddedMode(true); - - expect(router.isEmbeddedMode()).toBe(false); - }); - - it('isEmbeddedMode should always return false', () => { - expect(router.isEmbeddedMode()).toBe(false); - }); - }); - describe('getSettingsServicesPath', () => { it('should return the generic settings services path when no category given', () => { expect(router.getSettingsServicesPath()).toBe('/settings/services'); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ConnectionsRouterClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ConnectionsRouterClassBase.ts index f1460aa98c4c..1e9faf80de6d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ConnectionsRouterClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ConnectionsRouterClassBase.ts @@ -26,14 +26,6 @@ import { import { getServiceRouteFromServiceType } from './ServiceUtils'; class ConnectionsRouterClassBase { - public setEmbeddedMode(_flag: boolean): void { - // no-op in base; overridden in Collate - } - - public isEmbeddedMode(): boolean { - return false; - } - public getSettingsServicesPath(serviceCategory?: string): string { if (serviceCategory) { return getSettingPath( diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/KnowledgeGraph.utils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/KnowledgeGraph.utils.test.ts index 0d644a4415bf..cf91a169ee89 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/KnowledgeGraph.utils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/KnowledgeGraph.utils.test.ts @@ -34,6 +34,7 @@ import { transformToG6Format, } from './KnowledgeGraph.utils'; import ELKLayout from './Lineage/Layout/ELKUtil/ELKUtil'; +import { registerAppContextProvider } from './RouterUtils'; const makeNode = (id: string, extra: Record = {}) => ({ id, @@ -762,5 +763,37 @@ describe('KnowledgeGraph.utils', () => { openSpy.mockRestore(); }); + + describe('AppContextProvider integration', () => { + afterEach(() => { + registerAppContextProvider(null); + }); + + it('node:dblclick routes the URL through the registered AppContextProvider', () => { + // OM core must not hard-code any embed prefix; hosts register a provider + // to rewrite escape-hatch URLs. Here we simulate a host that prefixes + // every internal path and verify window.open receives the rewritten form. + const provider = jest.fn((p: string) => `/host${p}`); + registerAppContextProvider(provider); + + const openSpy = jest + .spyOn(window, 'open') + .mockImplementation(() => null); + const { ctx, graph } = buildCtx(); + setupGraphEventHandlers(ctx); + + const dblClickHandler = getHandler(graph, 'node:dblclick'); + dblClickHandler?.({ target: { id: 'B' } }); + + expect(provider).toHaveBeenCalledWith('/test/entity/path'); + expect(openSpy).toHaveBeenCalledWith( + '/host/test/entity/path', + '_blank', + 'noopener,noreferrer' + ); + + openSpy.mockRestore(); + }); + }); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/KnowledgeGraph.utils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/KnowledgeGraph.utils.ts index 11f895df419b..f2b21766fd0e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/KnowledgeGraph.utils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/KnowledgeGraph.utils.ts @@ -39,6 +39,7 @@ import { PRIMARY_COLOR } from '../constants/Color.constants'; import { EntityType } from '../enums/entity.enum'; import { getEntityLinkFromType } from './EntityUtils'; import ELKLayout from './Lineage/Layout/ELKUtil/ELKUtil'; +import { inCurrentAppContext } from './RouterUtils'; // Layout: padding(8) + icon(14) + gap(8) + label + gap(8) + typeChip + padding(8) // label: 14px bold ≈ 9.5px per char @@ -873,7 +874,12 @@ export const setupGraphEventHandlers = (ctx: GraphInteractionCtx): void => { const node = graphDataNodes.find((n) => n.id === nodeId); if (node?.type && node?.fullyQualifiedName) { window.open( - getEntityLinkFromType(node.fullyQualifiedName, node.type as EntityType), + inCurrentAppContext( + getEntityLinkFromType( + node.fullyQualifiedName, + node.type as EntityType + ) + ), '_blank', 'noopener,noreferrer' ); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ObservabilityRouterClassBase.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ObservabilityRouterClassBase.test.ts index 44c70d0ba7d9..8aa1679bd1e0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ObservabilityRouterClassBase.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ObservabilityRouterClassBase.test.ts @@ -65,18 +65,6 @@ describe('ObservabilityRouterClassBase', () => { router = new ObservabilityRouterClassBase(); }); - describe('embeddedMode', () => { - it('setEmbeddedMode should be a no-op', () => { - router.setEmbeddedMode(true); - - expect(router.isEmbeddedMode()).toBe(false); - }); - - it('isEmbeddedMode should always return false', () => { - expect(router.isEmbeddedMode()).toBe(false); - }); - }); - describe('getDataQualityPagePath', () => { it('should return base path without tab', () => { expect(router.getDataQualityPagePath()).toBe('/data-quality'); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ObservabilityRouterClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ObservabilityRouterClassBase.ts index 4581e1d37b1f..2362f5a5ec2c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ObservabilityRouterClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ObservabilityRouterClassBase.ts @@ -25,14 +25,6 @@ import { } from './RouterUtils'; class ObservabilityRouterClassBase { - public setEmbeddedMode(_flag: boolean): void { - // no-op in base; overridden in Collate - } - - public isEmbeddedMode(): boolean { - return false; - } - public getDataQualityPagePath( tab?: DataQualityPageTabs, subTab?: string diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.test.ts index 73e4ecfdb3cb..66558b1bf104 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.test.ts @@ -17,7 +17,12 @@ import { PLACEHOLDER_SETTING_CATEGORY, ROUTES, } from '../constants/constants'; -import { getSettingPath, getSettingsPathWithFqn } from './RouterUtils'; +import { + getSettingPath, + getSettingsPathWithFqn, + inCurrentAppContext, + registerAppContextProvider, +} from './RouterUtils'; describe('Global Setting routes', () => { describe('getSettingPath', () => { @@ -121,3 +126,56 @@ describe('Global Setting routes', () => { }); }); }); + +describe('AppContextProvider extension hook', () => { + afterEach(() => { + registerAppContextProvider(null); + }); + + it('returns input unchanged when no provider is registered (identity default)', () => { + expect(inCurrentAppContext('/glossary/foo')).toBe('/glossary/foo'); + expect(inCurrentAppContext('https://example.com/page')).toBe( + 'https://example.com/page' + ); + expect(inCurrentAppContext('')).toBe(''); + }); + + it('delegates to a registered provider', () => { + const provider = jest.fn((url: string) => `/prefix${url}`); + registerAppContextProvider(provider); + + const result = inCurrentAppContext('/glossary/foo'); + + expect(provider).toHaveBeenCalledWith('/glossary/foo'); + expect(result).toBe('/prefix/glossary/foo'); + }); + + it('replaces a previously registered provider when a new one registers', () => { + registerAppContextProvider(() => 'first'); + + expect(inCurrentAppContext('/anything')).toBe('first'); + + registerAppContextProvider(() => 'second'); + + expect(inCurrentAppContext('/anything')).toBe('second'); + }); + + it('restores identity behavior when null is registered', () => { + registerAppContextProvider((u) => `rewritten${u}`); + + expect(inCurrentAppContext('/x')).toBe('rewritten/x'); + + registerAppContextProvider(null); + + expect(inCurrentAppContext('/x')).toBe('/x'); + }); + + it('does not assume any product-specific prefix in OM core (stays product-agnostic)', () => { + // OM must NOT hard-code /askCollate or any embed prefix. The identity + // default proves that the open-source build leaves URLs alone. + expect(inCurrentAppContext('/askCollate/foo')).toBe('/askCollate/foo'); + expect(inCurrentAppContext('/observability/data-quality')).toBe( + '/observability/data-quality' + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts index 426666848f6b..82c20bc819b5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts @@ -459,6 +459,24 @@ export const getLogsViewerPath = ( return path; }; +// Extension hook for escape-hatch navigation (window.open, target=_blank, +// raw ) that bypasses React Router's basename. Hosts that embed OM +// under a sub-path register a provider; without one, input passes through. +export type AppContextProvider = (urlOrPath: string) => string; + +const identityAppContextProvider: AppContextProvider = (urlOrPath) => urlOrPath; + +let currentAppContextProvider: AppContextProvider = identityAppContextProvider; + +export const registerAppContextProvider = ( + provider: AppContextProvider | null +): void => { + currentAppContextProvider = provider ?? identityAppContextProvider; +}; + +export const inCurrentAppContext = (urlOrPath: string): string => + currentAppContextProvider(urlOrPath); + export const getGlossaryPathWithAction = ( fqn: string, action: EntityAction