Skip to content

Commit 8202977

Browse files
authored
fix(ui): open links from inside modal in a new tab (#14431)
1 parent 4e83f95 commit 8202977

File tree

7 files changed

+126
-4
lines changed

7 files changed

+126
-4
lines changed

datahub-web-react/src/alchemy-components/components/Modal/Modal.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Modal as AntModal, ModalProps as AntModalProps } from 'antd';
33
import React from 'react';
44
import styled from 'styled-components';
55

6+
import { ModalContext } from '@app/sharedV2/modals/ModalContext';
7+
68
const StyledModal = styled(AntModal)<{ hasChildren: boolean }>`
79
font-family: ${typography.fonts.body};
810
@@ -125,7 +127,7 @@ export function Modal({
125127
}
126128
{...props}
127129
>
128-
{children}
130+
<ModalContext.Provider value={{ isInsideModal: true }}>{children}</ModalContext.Provider>
129131
</StyledModal>
130132
);
131133
}

datahub-web-react/src/app/homeV3/module/components/EntityItem.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import styled from 'styled-components';
44

55
import analytics, { EventType } from '@app/analytics';
66
import AutoCompleteEntityItem from '@app/searchV2/autoCompleteV2/AutoCompleteEntityItem';
7+
import { useGetModalLinkProps } from '@app/sharedV2/modals/useGetModalLinkProps';
78
import { useEntityRegistryV2 } from '@app/useEntityRegistry';
89

910
import { DataHubPageModuleType, Entity } from '@types';
@@ -34,6 +35,7 @@ export default function EntityItem({
3435
padding,
3536
}: Props) {
3637
const entityRegistry = useEntityRegistryV2();
38+
const linkProps = useGetModalLinkProps();
3739

3840
const sendAnalytics = useCallback(
3941
() =>
@@ -60,7 +62,11 @@ export default function EntityItem({
6062
onClick={sendAnalytics}
6163
/>
6264
) : (
63-
<StyledLink to={entityRegistry.getEntityUrl(entity.type, entity.urn)} onClick={sendAnalytics}>
65+
<StyledLink
66+
to={entityRegistry.getEntityUrl(entity.type, entity.urn)}
67+
onClick={sendAnalytics}
68+
{...linkProps}
69+
>
6470
<AutoCompleteEntityItem
6571
entity={entity}
6672
key={entity.urn}

datahub-web-react/src/app/searchV2/autoCompleteV2/AutoCompleteEntityItem.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import EntitySubtitle from '@app/searchV2/autoCompleteV2/components/subtitle/Ent
1212
import { VARIANT_STYLES } from '@app/searchV2/autoCompleteV2/constants';
1313
import { EntityItemVariant } from '@app/searchV2/autoCompleteV2/types';
1414
import { getEntityDisplayType } from '@app/searchV2/autoCompleteV2/utils';
15+
import { useGetModalLinkProps } from '@app/sharedV2/modals/useGetModalLinkProps';
1516
import { Text } from '@src/alchemy-components';
1617
import { useEntityRegistryV2 } from '@src/app/useEntityRegistry';
1718
import { Entity, MatchedField } from '@src/types.generated';
@@ -131,6 +132,8 @@ export default function AutoCompleteEntityItem({
131132
}: EntityAutocompleteItemProps) {
132133
const theme = useTheme();
133134
const entityRegistry = useEntityRegistryV2();
135+
const linkProps = useGetModalLinkProps();
136+
134137
const displayName = entityRegistry.getDisplayName(entity.type, entity);
135138
const displayType = getEntityDisplayType(entity, entityRegistry);
136139
const variantProps = VARIANT_STYLES.get(variant ?? 'default');
@@ -140,7 +143,7 @@ export default function AutoCompleteEntityItem({
140143
: DisplayNameHoverFromContainer;
141144

142145
const displayNameContent = variantProps?.nameCanBeHovered ? (
143-
<Link to={entityRegistry.getEntityUrl(entity.type, entity.urn)}>
146+
<Link to={entityRegistry.getEntityUrl(entity.type, entity.urn)} {...linkProps}>
144147
<DisplayNameHoverComponent
145148
displayName={displayName}
146149
highlight={query}

datahub-web-react/src/app/shared/useEmbeddedProfileLinkProps.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useMemo } from 'react';
22

3+
import { useModalContext } from '@app/sharedV2/modals/ModalContext';
34
import { PageRoutes } from '@conf/Global';
45

56
// Function to check if the current page is an embedded profile
@@ -11,5 +12,9 @@ export const useIsEmbeddedProfile = () => {
1112

1213
export const useEmbeddedProfileLinkProps = () => {
1314
const isEmbedded = useIsEmbeddedProfile();
14-
return useMemo(() => (isEmbedded ? { target: '_blank', rel: 'noreferrer noopener' } : {}), [isEmbedded]);
15+
const { isInsideModal } = useModalContext(); // If link is opened from inside a modal
16+
return useMemo(
17+
() => (isEmbedded || isInsideModal ? { target: '_blank', rel: 'noreferrer noopener' } : {}),
18+
[isEmbedded, isInsideModal],
19+
);
1520
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { createContext, useContext } from 'react';
2+
3+
export type ModalContextType = {
4+
isInsideModal: boolean;
5+
};
6+
7+
export const ModalContext = createContext<ModalContextType>({ isInsideModal: false });
8+
9+
export const useModalContext = () => useContext(ModalContext);
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
import React, { PropsWithChildren } from 'react';
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { ModalContext, ModalContextType } from '@app/sharedV2/modals/ModalContext';
6+
import { useGetModalLinkProps } from '@app/sharedV2/modals/useGetModalLinkProps';
7+
8+
function wrapperWithContext(value: ModalContextType) {
9+
return ({ children }: { children: React.ReactNode }) => (
10+
<ModalContext.Provider value={value}>{children}</ModalContext.Provider>
11+
);
12+
}
13+
14+
describe('useGetModalLinkProps', () => {
15+
it('should return an empty object when not in modal (default context)', () => {
16+
const { result } = renderHook(() => useGetModalLinkProps());
17+
expect(result.current).toEqual({});
18+
});
19+
20+
it('should return correct props when isInsideModal = true', () => {
21+
const { result } = renderHook(() => useGetModalLinkProps(), {
22+
wrapper: wrapperWithContext({ isInsideModal: true }),
23+
});
24+
expect(result.current).toEqual({
25+
target: '_blank',
26+
rel: 'noopener noreferrer',
27+
});
28+
});
29+
30+
it('should return empty object when isInsideModal = false', () => {
31+
const { result } = renderHook(() => useGetModalLinkProps(), {
32+
wrapper: wrapperWithContext({ isInsideModal: false }),
33+
});
34+
expect(result.current).toEqual({});
35+
});
36+
37+
it('should memoize result for same context value', () => {
38+
const { result, rerender } = renderHook(() => useGetModalLinkProps(), {
39+
wrapper: wrapperWithContext({ isInsideModal: true }),
40+
});
41+
const first = result.current;
42+
rerender();
43+
expect(result.current).toBe(first); // stable reference
44+
});
45+
46+
it('should change reference when context value changes', () => {
47+
const wrapper = ({ children, value }: PropsWithChildren<{ value: boolean }>) => (
48+
<ModalContext.Provider value={{ isInsideModal: value }}>{children}</ModalContext.Provider>
49+
);
50+
51+
const { result, rerender } = renderHook(() => useGetModalLinkProps(), {
52+
initialProps: { value: false },
53+
wrapper,
54+
});
55+
56+
const first = result.current;
57+
rerender({ value: true });
58+
expect(result.current).not.toBe(first);
59+
expect(result.current).toEqual({
60+
target: '_blank',
61+
rel: 'noopener noreferrer',
62+
});
63+
});
64+
65+
it('should have no extra keys when not in modal', () => {
66+
const { result } = renderHook(() => useGetModalLinkProps(), {
67+
wrapper: wrapperWithContext({ isInsideModal: false }),
68+
});
69+
expect(Object.keys(result.current)).toHaveLength(0);
70+
});
71+
72+
it('should match snapshot when in modal', () => {
73+
const { result } = renderHook(() => useGetModalLinkProps(), {
74+
wrapper: wrapperWithContext({ isInsideModal: true }),
75+
});
76+
expect(result.current).toMatchInlineSnapshot(`
77+
{
78+
"rel": "noopener noreferrer",
79+
"target": "_blank",
80+
}
81+
`);
82+
});
83+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useMemo } from 'react';
2+
3+
import { useModalContext } from '@app/sharedV2/modals/ModalContext';
4+
5+
export const useGetModalLinkProps = () => {
6+
const { isInsideModal } = useModalContext();
7+
8+
return useMemo(() => {
9+
if (isInsideModal) {
10+
return { target: '_blank', rel: 'noopener noreferrer' };
11+
}
12+
return {};
13+
}, [isInsideModal]);
14+
};

0 commit comments

Comments
 (0)