Skip to content
Open
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 @@ -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';
Expand Down Expand Up @@ -90,7 +91,7 @@ const EditorSlots = forwardRef<EditorSlotsRef, EditorSlotsProps>(
const href = target.getAttribute('href');
const linkTarget = target.getAttribute('target');
if (href && linkTarget) {
window.open(href, linkTarget);
window.open(inCurrentAppContext(href), linkTarget);
Comment thread
gitar-bot[bot] marked this conversation as resolved.
}

return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
import {
getEntityDetailsPath,
getGlossaryTermDetailsPath,
inCurrentAppContext,
} from '../../../utils/RouterUtils';
import { getTermQuery } from '../../../utils/SearchUtils';
import { showErrorToast } from '../../../utils/ToastUtils';
Expand Down Expand Up @@ -1467,7 +1468,7 @@ export function useOntologyExplorer({
if (!path) {
return;
}
window.open(path, '_blank');
window.open(inCurrentAppContext(path), '_blank');
},
[getNodePath]
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
Comment on lines +110 to +113
} else {
navigate(getSettingPath(category, option));
}

break;
}
},
[isEmbedded, navigate]
);

return (
<PageLayoutV1 pageTitle={t('label.setting-plural')}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
setEditorContent,
transformImgTagsToFileAttachment,
} from './BlockEditorUtils';
import { registerAppContextProvider } from './RouterUtils';

describe('getTextFromHtmlString', () => {
it('should return empty string when input is undefined', () => {
Expand Down Expand Up @@ -162,6 +163,41 @@ describe('formatContent', () => {
'<p>This <a data-type="mention" data-label="Infrastructure" href="http://localhost:3000/settings/members/teams/Infrastructure" data-entitytype="team" data-fqn="Infrastructure"><#E::team::Infrastructure|[@Infrastructure](http://localhost:3000/settings/members/teams/Infrastructure)></a> team</p>'
);
});

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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@
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;
Expand Down Expand Up @@ -104,7 +108,7 @@
const parser = new DOMParser();

// Only convert markdown to HTML if the content is not already HTML
const processedContent = isHTMLString(htmlString)

Check warning on line 111 in openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts

View workflow job for this annotation

GitHub Actions / lint-src

'isHTMLString' was used before it was defined
? htmlString
: _convertMarkdownFormatToHtmlString(htmlString);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) => ({
id,
Expand Down Expand Up @@ -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();
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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'
);
});
});
Loading
Loading