Skip to content

Commit 50ebf0a

Browse files
philip-nikolov-jpmcdeepgarg760chriscollins3456
authored
Datahub as a microfrontend host (react) (#15358)
Co-authored-by: Deepak Garg <[email protected]> Co-authored-by: Chris Collins <[email protected]>
1 parent 57a407f commit 50ebf0a

File tree

16 files changed

+1300
-4
lines changed

16 files changed

+1300
-4
lines changed

datahub-web-react/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"history": "^5.0.0",
7373
"html-to-image": "^1.11.11",
7474
"js-cookie": "^2.2.1",
75+
"js-yaml": "^4.1.0",
7576
"moment": "^2.29.4",
7677
"moment-timezone": "^0.5.35",
7778
"monaco-editor": "^0.28.1",
@@ -145,6 +146,7 @@
145146
"@graphql-codegen/near-operation-file-preset": "^1.17.13",
146147
"@graphql-codegen/typescript-operations": "1.17.13",
147148
"@graphql-codegen/typescript-react-apollo": "2.2.1",
149+
"@originjs/vite-plugin-federation": "^1.4.1",
148150
"@storybook/addon-essentials": "^8.1.11",
149151
"@storybook/addon-interactions": "^8.1.11",
150152
"@storybook/addon-links": "^8.1.11",

datahub-web-react/src/app/SearchRoutes.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import GlossaryRoutesV2 from '@app/glossaryV2/GlossaryRoutes';
1818
import StructuredProperties from '@app/govern/structuredProperties/StructuredProperties';
1919
import { ManageIngestionPage } from '@app/ingest/ManageIngestionPage';
2020
import IngestionRoutes from '@app/ingestV2/IngestionRoutes';
21+
import { MFERoutes } from '@app/mfeframework/mfeConfigLoader';
2122
import { SearchPage } from '@app/search/SearchPage';
2223
import { SearchablePage } from '@app/search/SearchablePage';
2324
import { SearchPage as SearchPageV2 } from '@app/searchV2/SearchPage';
@@ -138,6 +139,7 @@ export const SearchRoutes = (): JSX.Element => {
138139
return <NoPageFound />;
139140
}}
140141
/>
142+
<Route path="/mfe*" component={MFERoutes} />
141143
{me.loaded && loaded && <Route component={NoPageFound} />}
142144
</Switch>
143145
</FinalSearchablePage>

datahub-web-react/src/app/homeV2/layout/navBarRedesign/NavBarMenuItemDropdown.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export default function NavBarMenuItemDropdown({ item, isCollapsed, isSelected,
4444
const history = useHistory();
4545

4646
const dropdownItems = item.items?.filter((subItem) => !subItem.isHidden);
47+
const shouldScroll = item.key === 'mfe-dropdown' && dropdownItems && dropdownItems.length > 5; // 5 can be changed depending on requirement
4748

4849
const onItemClick = (key) => {
4950
analytics.event({ type: EventType.NavBarItemClick, label: item.title });
@@ -52,6 +53,10 @@ export default function NavBarMenuItemDropdown({ item, isCollapsed, isSelected,
5253

5354
if (clickedItem.disabled) return null;
5455

56+
if (item.key === 'mfe-dropdown' && clickedItem.link) {
57+
return history.push(clickedItem.link);
58+
}
59+
5560
if (clickedItem.onClick) return clickedItem.onClick();
5661

5762
if (clickedItem.link && clickedItem.isExternalLink)
@@ -65,7 +70,7 @@ export default function NavBarMenuItemDropdown({ item, isCollapsed, isSelected,
6570
<Dropdown
6671
dropdownRender={() => {
6772
return (
68-
<StyledDropdownContentWrapper>
73+
<StyledDropdownContentWrapper style={shouldScroll ? { maxHeight: 200, overflowY: 'auto' } : {}}>
6974
{dropdownItems?.map((dropdownItem) => {
7075
return (
7176
<StyledDropDownOption
@@ -76,7 +81,20 @@ export default function NavBarMenuItemDropdown({ item, isCollapsed, isSelected,
7681
aria-disabled={dropdownItem.disabled}
7782
onClick={() => onItemClick(dropdownItem.key)}
7883
>
79-
<Text>{dropdownItem.title}</Text>
84+
{item.key === 'mfe-dropdown' ? (
85+
// Flex container for icon and title only for key "mfe"
86+
<div style={{ display: 'flex', alignItems: 'center' }}>
87+
{dropdownItem.icon && (
88+
<span style={{ marginRight: 8, display: 'flex', alignItems: 'center' }}>
89+
{dropdownItem.icon}
90+
</span>
91+
)}
92+
<Text>{dropdownItem.title}</Text>
93+
</div>
94+
) : (
95+
// Default rendering for other items
96+
<Text>{dropdownItem.title}</Text>
97+
)}
8098
<Text size="sm" color="gray">
8199
{dropdownItem.description}
82100
</Text>

datahub-web-react/src/app/homeV2/layout/navBarRedesign/NavSidebar.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,17 @@ import NavBarHeader from '@app/homeV2/layout/navBarRedesign/NavBarHeader';
2424
import NavBarMenu from '@app/homeV2/layout/navBarRedesign/NavBarMenu';
2525
import NavSkeleton from '@app/homeV2/layout/navBarRedesign/NavBarSkeleton';
2626
import {
27+
NavBarMenuDropdownItem,
2728
NavBarMenuDropdownItemElement,
29+
NavBarMenuGroup,
2830
NavBarMenuItemTypes,
2931
NavBarMenuItems,
3032
} from '@app/homeV2/layout/navBarRedesign/types';
3133
import useSelectedKey from '@app/homeV2/layout/navBarRedesign/useSelectedKey';
3234
import { useContextMenuItems } from '@app/homeV2/layout/sidebar/documents/useContextMenuItems';
3335
import { useShowHomePageRedesign } from '@app/homeV3/context/hooks/useShowHomePageRedesign';
36+
import { useMFEConfigFromBackend } from '@app/mfeframework/mfeConfigLoader';
37+
import { getMfeMenuDropdownItems, getMfeMenuItems } from '@app/mfeframework/mfeNavBarMenuUtils';
3438
import OnboardingContext from '@app/onboarding/OnboardingContext';
3539
import { useOnboardingTour } from '@app/onboarding/OnboardingTourContext.hooks';
3640
import { useIsHomePage } from '@app/shared/useIsHomePage';
@@ -172,6 +176,33 @@ export const NavSidebar = () => {
172176
key: `helpMenu${value.label}`,
173177
})) as NavBarMenuDropdownItemElement[];
174178

179+
// --- MFE YAML CONFIG ---
180+
const mfeConfig: any = useMFEConfigFromBackend();
181+
182+
// MFE section (dropdown or spread)
183+
let mfeSection: any[] = [];
184+
if (mfeConfig) {
185+
if (mfeConfig.subNavigationMode) {
186+
mfeSection = [
187+
{
188+
type: NavBarMenuItemTypes.Dropdown,
189+
title: 'MFE Apps',
190+
icon: <AppWindow />,
191+
key: 'mfe-dropdown',
192+
items: getMfeMenuDropdownItems(mfeConfig),
193+
} as NavBarMenuDropdownItem,
194+
];
195+
} else {
196+
mfeSection = [
197+
{
198+
type: NavBarMenuItemTypes.Group,
199+
key: 'mfe-group',
200+
title: 'MFE Apps',
201+
items: getMfeMenuItems(mfeConfig),
202+
} as NavBarMenuGroup,
203+
];
204+
}
205+
}
175206
function handleHomeclick() {
176207
if (isHomePage && showHomepageRedesign) {
177208
toggle();
@@ -195,6 +226,7 @@ export const NavSidebar = () => {
195226

196227
const mainContentMenu: NavBarMenuItems = {
197228
items: [
229+
...mfeSection,
198230
{
199231
type: NavBarMenuItemTypes.Group,
200232
key: 'govern',

datahub-web-react/src/app/homeV2/layout/navBarRedesign/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface NavBarMenuBaseElement {
2323
disabled?: boolean;
2424
href?: string;
2525
dataTestId?: string;
26+
icon?: React.ReactNode;
2627
}
2728

2829
export type Badge = {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import React from 'react';
2+
import styled from 'styled-components';
3+
4+
const ErrorContainer = styled.div<{ isV2: boolean }>`
5+
display: flex;
6+
align-items: center;
7+
justify-content: center;
8+
min-height: 480px;
9+
background-color: ${(props) => (props.isV2 ? '#f5f5f5' : '#fafafa')};
10+
border: 2px dashed ${(props) => (props.isV2 ? '#d9d9d9' : '#e8e8e8')};
11+
border-radius: 8px;
12+
color: ${(props) => (props.isV2 ? '#595959' : '#666')};
13+
font-size: 16px;
14+
text-align: center;
15+
padding: 20px;
16+
`;
17+
18+
export const ErrorComponent = ({ isV2, message }: { isV2: boolean; message: string }) => {
19+
return <ErrorContainer isV2={isV2}>{message}</ErrorContainer>;
20+
};
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import React, { useEffect, useRef, useState } from 'react';
2+
import { useHistory } from 'react-router-dom';
3+
import styled from 'styled-components';
4+
import {
5+
__federation_method_getRemote as getRemote,
6+
__federation_method_setRemote as setRemote,
7+
__federation_method_unwrapDefault as unwrapModule,
8+
} from 'virtual:__federation__';
9+
10+
import { ErrorComponent } from '@app/mfeframework/ErrorComponent';
11+
import { MFEConfig } from '@app/mfeframework/mfeConfigLoader';
12+
import { useIsThemeV2 } from '@app/useIsThemeV2';
13+
import { useShowNavBarRedesign } from '@app/useShowNavBarRedesign';
14+
15+
const MFEConfigurableContainer = styled.div<{ isV2: boolean; $isShowNavBarRedesign?: boolean }>`
16+
background-color: ${(props) => (props.isV2 ? '#fff' : 'inherit')};
17+
padding: 16px;
18+
${(props) =>
19+
props.$isShowNavBarRedesign &&
20+
`
21+
height: 100%;
22+
margin: 5px;
23+
overflow: auto;
24+
box-shadow: ${props.theme.styles['box-shadow-navbar-redesign']};
25+
`}
26+
${(props) =>
27+
!props.$isShowNavBarRedesign &&
28+
`
29+
margin-right: ${props.isV2 ? '24px' : '0'};
30+
margin-bottom: ${props.isV2 ? '24px' : '0'};
31+
`}
32+
border-radius: ${(props) => {
33+
if (props.isV2 && props.$isShowNavBarRedesign) return props.theme.styles['border-radius-navbar-redesign'];
34+
return props.isV2 ? '8px' : '0';
35+
}};
36+
`;
37+
38+
interface MountMFEParams {
39+
config: MFEConfig;
40+
containerElement: HTMLDivElement | null;
41+
onError: () => void;
42+
aliveRef: { current: boolean };
43+
}
44+
45+
async function mountMFE({
46+
config,
47+
containerElement,
48+
onError,
49+
aliveRef,
50+
}: MountMFEParams): Promise<(() => void) | undefined> {
51+
const { module, remoteEntry } = config;
52+
const mountStart = performance.now();
53+
54+
if (import.meta.env.DEV) {
55+
console.log('MFE id: ', config.id, ' Mounting start ');
56+
}
57+
try {
58+
if (import.meta.env.DEV) {
59+
console.log('[HOST] mount path: ', module);
60+
console.log('[HOST] attempting mount');
61+
}
62+
63+
// Parse module string, something like: "myapp/mount"
64+
const [remoteName, modulePath] = module.split('/');
65+
const modulePathWithDot = `./${modulePath}`; // Convert "mount" to "./mount"
66+
67+
if (import.meta.env.DEV) {
68+
console.log('[HOST] parsed remote name: ', remoteName);
69+
console.log('[HOST] parsed module path: ', modulePathWithDot);
70+
}
71+
72+
// Configure the dynamic remote
73+
const remoteConfig = {
74+
url: remoteEntry,
75+
format: 'var' as const,
76+
from: 'webpack' as const,
77+
};
78+
setRemote(remoteName, remoteConfig);
79+
80+
// Create a timeout promise that rejects in a few seconds
81+
const timeoutPromise = new Promise((_, reject) => {
82+
setTimeout(
83+
() => reject(new Error(`Timeout loading from remote ${remoteName}, module: ${modulePathWithDot}`)),
84+
5000,
85+
);
86+
});
87+
88+
// Race between getRemote and timeout
89+
const fetchStart = performance.now();
90+
if (import.meta.env.DEV) {
91+
console.log('[HOST] Attempting to load remote module with config:', remoteConfig);
92+
}
93+
const remoteModule = await Promise.race([getRemote(remoteName, modulePathWithDot), timeoutPromise]);
94+
const fetchEnd = performance.now();
95+
if (import.meta.env.DEV) {
96+
console.log(`latency for remote module fetch: ${config.id}`, fetchEnd - fetchStart, 'ms');
97+
console.log('[HOST] Remote module loaded, unwrapping...');
98+
}
99+
const unwrapStart = performance.now();
100+
const mod = await unwrapModule(remoteModule);
101+
const unwrapEnd = performance.now();
102+
if (import.meta.env.DEV) {
103+
console.log(`latency for module unwrap: ${config.id}`, unwrapEnd - unwrapStart, 'ms');
104+
console.log('[HOST] imported mod: ', mod);
105+
console.log('[HOST] mod type: ', typeof mod);
106+
}
107+
108+
const maybeFn =
109+
typeof mod === 'function'
110+
? mod
111+
: ((mod as any)?.mount ??
112+
(typeof (mod as any)?.default === 'function' ? (mod as any).default : (mod as any)?.default?.mount));
113+
114+
if (!aliveRef.current) {
115+
console.error('[HOST] import/mount has failed due to timeout.');
116+
return undefined;
117+
}
118+
if (!config.flags.enabled) {
119+
console.warn(
120+
'[HOST] skipping remote module loading for<config.id> because planning not to show it, enabled=false',
121+
);
122+
return undefined;
123+
}
124+
125+
if (!containerElement) {
126+
console.warn('[HOST] ref is null (container div not in DOM');
127+
return undefined;
128+
}
129+
130+
if (typeof maybeFn !== 'function') {
131+
if (import.meta.env.DEV) {
132+
console.warn('MFE id: ', config.id, ' Mounting failed');
133+
console.warn('[HOST] mount is not a function; got: ', maybeFn);
134+
}
135+
return undefined;
136+
}
137+
const mountFnStart = performance.now();
138+
const cleanup = maybeFn(containerElement, {});
139+
const mountFnEnd = performance.now();
140+
if (import.meta.env.DEV) {
141+
console.log(`latency for mount function execution: ${config.id}`, mountFnEnd - mountFnStart, 'ms');
142+
console.log('[HOST] mount called');
143+
}
144+
const mountEnd = performance.now();
145+
const latency = mountEnd - mountStart;
146+
if (import.meta.env.DEV) {
147+
console.log(`latency for successful MFE id: ${config.id}`, latency, 'ms');
148+
}
149+
return cleanup;
150+
} catch (e) {
151+
if (import.meta.env.DEV) {
152+
console.log(`latency for unsuccessful MFE id: ${config.id}`, performance.now() - mountStart, 'ms');
153+
console.error('[HOST] import/mount failed:', e);
154+
}
155+
if (aliveRef.current) {
156+
onError();
157+
}
158+
return undefined;
159+
}
160+
}
161+
162+
export const MFEBaseConfigurablePage = ({ config }: { config: MFEConfig }) => {
163+
const isV2 = useIsThemeV2();
164+
const isShowNavBarRedesign = useShowNavBarRedesign();
165+
const box = useRef<HTMLDivElement>(null);
166+
const history = useHistory();
167+
const [hasError, setHasError] = useState(false);
168+
const aliveRef = useRef(true);
169+
170+
useEffect(() => {
171+
aliveRef.current = true;
172+
let cleanup: (() => void) | undefined;
173+
174+
mountMFE({
175+
config,
176+
containerElement: box.current,
177+
onError: () => setHasError(true),
178+
aliveRef,
179+
}).then((cleanupFn) => {
180+
cleanup = cleanupFn;
181+
});
182+
183+
return () => {
184+
aliveRef.current = false;
185+
if (cleanup) {
186+
if (import.meta.env.DEV) {
187+
console.log('[HOST] Executing cleanup method provided by mount');
188+
}
189+
const cleanupStart = performance.now();
190+
cleanup();
191+
const cleanupEnd = performance.now();
192+
if (import.meta.env.DEV) {
193+
console.log(`latency for cleanup execution: ${config.id}`, cleanupEnd - cleanupStart, 'ms');
194+
}
195+
}
196+
};
197+
}, [config, history]);
198+
199+
if (hasError) {
200+
return <ErrorComponent isV2={isV2} message={`${config.label} is not available at this time`} />;
201+
}
202+
if (!config.flags.enabled) {
203+
return <ErrorComponent isV2={isV2} message={`${config.label} is disabled.`} />;
204+
}
205+
206+
return (
207+
<MFEConfigurableContainer
208+
isV2={isV2}
209+
$isShowNavBarRedesign={isShowNavBarRedesign}
210+
data-testid="mfe-configurable-container"
211+
>
212+
<div ref={box} style={{ minHeight: 480 }} />
213+
</MFEConfigurableContainer>
214+
);
215+
};

0 commit comments

Comments
 (0)