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
2 changes: 2 additions & 0 deletions datahub-web-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"history": "^5.0.0",
"html-to-image": "^1.11.11",
"js-cookie": "^2.2.1",
"js-yaml": "^4.1.0",
"moment": "^2.29.4",
"moment-timezone": "^0.5.35",
"monaco-editor": "^0.28.1",
Expand Down Expand Up @@ -145,6 +146,7 @@
"@graphql-codegen/near-operation-file-preset": "^1.17.13",
"@graphql-codegen/typescript-operations": "1.17.13",
"@graphql-codegen/typescript-react-apollo": "2.2.1",
"@originjs/vite-plugin-federation": "^1.4.1",
"@storybook/addon-essentials": "^8.1.11",
"@storybook/addon-interactions": "^8.1.11",
"@storybook/addon-links": "^8.1.11",
Expand Down
2 changes: 2 additions & 0 deletions datahub-web-react/src/app/SearchRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import GlossaryRoutesV2 from '@app/glossaryV2/GlossaryRoutes';
import StructuredProperties from '@app/govern/structuredProperties/StructuredProperties';
import { ManageIngestionPage } from '@app/ingest/ManageIngestionPage';
import { ManageIngestionPage as ManageIngestionPageV2 } from '@app/ingestV2/ManageIngestionPage';
import { MFERoutes } from '@app/mfeframework/mfeConfigLoader';
import { SearchPage } from '@app/search/SearchPage';
import { SearchablePage } from '@app/search/SearchablePage';
import { SearchPage as SearchPageV2 } from '@app/searchV2/SearchPage';
Expand Down Expand Up @@ -138,6 +139,7 @@ export const SearchRoutes = (): JSX.Element => {
return <NoPageFound />;
}}
/>
<Route path="/mfe*" component={MFERoutes} />
{me.loaded && loaded && <Route component={NoPageFound} />}
</Switch>
</FinalSearchablePage>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default function NavBarMenuItemDropdown({ item, isCollapsed, isSelected,
const history = useHistory();

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

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

if (clickedItem.disabled) return null;

if (item.key === 'mfe-dropdown' && clickedItem.link) {
return history.push(clickedItem.link);
}

if (clickedItem.onClick) return clickedItem.onClick();

if (clickedItem.link && clickedItem.isExternalLink)
Expand All @@ -65,7 +70,7 @@ export default function NavBarMenuItemDropdown({ item, isCollapsed, isSelected,
<Dropdown
dropdownRender={() => {
return (
<StyledDropdownContentWrapper>
<StyledDropdownContentWrapper style={shouldScroll ? { maxHeight: 200, overflowY: 'auto' } : {}}>
{dropdownItems?.map((dropdownItem) => {
return (
<StyledDropDownOption
Expand All @@ -76,7 +81,20 @@ export default function NavBarMenuItemDropdown({ item, isCollapsed, isSelected,
aria-disabled={dropdownItem.disabled}
onClick={() => onItemClick(dropdownItem.key)}
>
<Text>{dropdownItem.title}</Text>
{item.key === 'mfe-dropdown' ? (
// Flex container for icon and title only for key "mfe"
<div style={{ display: 'flex', alignItems: 'center' }}>
{dropdownItem.icon && (
<span style={{ marginRight: 8, display: 'flex', alignItems: 'center' }}>
{dropdownItem.icon}
</span>
)}
<Text>{dropdownItem.title}</Text>
</div>
) : (
// Default rendering for other items
<Text>{dropdownItem.title}</Text>
)}
<Text size="sm" color="gray">
{dropdownItem.description}
</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,16 @@ import NavBarHeader from '@app/homeV2/layout/navBarRedesign/NavBarHeader';
import NavBarMenu from '@app/homeV2/layout/navBarRedesign/NavBarMenu';
import NavSkeleton from '@app/homeV2/layout/navBarRedesign/NavBarSkeleton';
import {
NavBarMenuDropdownItem,
NavBarMenuDropdownItemElement,
NavBarMenuGroup,
NavBarMenuItemTypes,
NavBarMenuItems,
} from '@app/homeV2/layout/navBarRedesign/types';
import useSelectedKey from '@app/homeV2/layout/navBarRedesign/useSelectedKey';
import { useShowHomePageRedesign } from '@app/homeV3/context/hooks/useShowHomePageRedesign';
import { useMFEConfigFromBackend } from '@app/mfeframework/mfeConfigLoader';
import { getMfeMenuDropdownItems, getMfeMenuItems } from '@app/mfeframework/mfeNavBarMenuUtils';
import OnboardingContext from '@app/onboarding/OnboardingContext';
import { useOnboardingTour } from '@app/onboarding/OnboardingTourContext.hooks';
import { useIsHomePage } from '@app/shared/useIsHomePage';
Expand Down Expand Up @@ -133,6 +137,33 @@ export const NavSidebar = () => {
key: `helpMenu${value.label}`,
})) as NavBarMenuDropdownItemElement[];

// --- MFE YAML CONFIG ---
const mfeConfig: any = useMFEConfigFromBackend();

// MFE section (dropdown or spread)
let mfeSection: any[] = [];
if (mfeConfig) {
if (mfeConfig.subNavigationMode) {
Copy link
Contributor

Choose a reason for hiding this comment

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

this is great. thanks for accommodating subNavigation

mfeSection = [
{
type: NavBarMenuItemTypes.Dropdown,
title: 'MFE Apps',
icon: <AppWindow />,
key: 'mfe-dropdown',
items: getMfeMenuDropdownItems(mfeConfig),
} as NavBarMenuDropdownItem,
];
} else {
mfeSection = [
{
type: NavBarMenuItemTypes.Group,
key: 'mfe-group',
title: 'MFE Apps',
items: getMfeMenuItems(mfeConfig),
} as NavBarMenuGroup,
];
}
}
function handleHomeclick() {
if (isHomePage && showHomepageRedesign) {
toggle();
Expand All @@ -151,6 +182,7 @@ export const NavSidebar = () => {
onlyExactPathMapping: true,
onClick: () => handleHomeclick(),
},
...mfeSection,
{
type: NavBarMenuItemTypes.Group,
key: 'govern',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface NavBarMenuBaseElement {
disabled?: boolean;
href?: string;
dataTestId?: string;
icon?: React.ReactNode;
}

export type Badge = {
Expand Down
20 changes: 20 additions & 0 deletions datahub-web-react/src/app/mfeframework/ErrorComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';
import styled from 'styled-components';

const ErrorContainer = styled.div<{ isV2: boolean }>`
display: flex;
align-items: center;
justify-content: center;
min-height: 480px;
background-color: ${(props) => (props.isV2 ? '#f5f5f5' : '#fafafa')};
border: 2px dashed ${(props) => (props.isV2 ? '#d9d9d9' : '#e8e8e8')};
border-radius: 8px;
color: ${(props) => (props.isV2 ? '#595959' : '#666')};
font-size: 16px;
text-align: center;
padding: 20px;
`;

export const ErrorComponent = ({ isV2, message }: { isV2: boolean; message: string }) => {
return <ErrorContainer isV2={isV2}>{message}</ErrorContainer>;
};
191 changes: 191 additions & 0 deletions datahub-web-react/src/app/mfeframework/MFEConfigurableContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import React, { useEffect, useRef, useState } from 'react';
Copy link
Contributor

Choose a reason for hiding this comment

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

also it'll be great if we could cleanup the logs and performance monitoring that's not necessary.

import { useHistory } from 'react-router-dom';
import styled from 'styled-components';
import {
__federation_method_getRemote as getRemote,
__federation_method_setRemote as setRemote,
__federation_method_unwrapDefault as unwrapModule,
} from 'virtual:__federation__';

import { ErrorComponent } from '@app/mfeframework/ErrorComponent';
import { MFEConfig } from '@app/mfeframework/mfeConfigLoader';
import { useIsThemeV2 } from '@app/useIsThemeV2';
import { useShowNavBarRedesign } from '@app/useShowNavBarRedesign';

const MFEConfigurableContainer = styled.div<{ isV2: boolean; $isShowNavBarRedesign?: boolean }>`
background-color: ${(props) => (props.isV2 ? '#fff' : 'inherit')};
padding: 16px;
${(props) =>
props.$isShowNavBarRedesign &&
`
height: 100%;
margin: 5px;
overflow: auto;
box-shadow: ${props.theme.styles['box-shadow-navbar-redesign']};
`}
${(props) =>
!props.$isShowNavBarRedesign &&
`
margin-right: ${props.isV2 ? '24px' : '0'};
margin-bottom: ${props.isV2 ? '24px' : '0'};
`}
border-radius: ${(props) => {
if (props.isV2 && props.$isShowNavBarRedesign) return props.theme.styles['border-radius-navbar-redesign'];
return props.isV2 ? '8px' : '0';
}};
`;

interface MountMFEParams {
config: MFEConfig;
containerElement: HTMLDivElement | null;
onError: () => void;
aliveRef: { current: boolean };
}

async function mountMFE({
config,
containerElement,
onError,
aliveRef,
}: MountMFEParams): Promise<(() => void) | undefined> {
const { module, remoteEntry } = config;
const mountStart = performance.now();

console.log('MFE id: ', config.id, ' Mounting start ');
try {
console.log('[HOST] mount path: ', module);
console.log('[HOST] attempting mount');

// Parse module string, something like: "myapp/mount"
const [remoteName, modulePath] = module.split('/');
Copy link
Contributor

Choose a reason for hiding this comment

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

since we expect exactly 1 / in the module, i think it's worthwhile to also validate in the configLoader and also add a comment in the MFEConfig type

const modulePathWithDot = `./${modulePath}`; // Convert "mount" to "./mount"

console.log('[HOST] parsed remote name: ', remoteName);
console.log('[HOST] parsed module path: ', modulePathWithDot);

// Configure the dynamic remote
const remoteConfig = {
url: remoteEntry,
format: 'var' as const,
from: 'webpack' as const,
};
setRemote(remoteName, remoteConfig);

// Create a timeout promise that rejects in a few seconds
const timeoutPromise = new Promise((_, reject) => {
setTimeout(
() => reject(new Error(`Timeout loading from remote ${remoteName}, module: ${modulePathWithDot}`)),
5000,
);
});

// Race between getRemote and timeout
const fetchStart = performance.now();
console.log('[HOST] Attempting to load remote module with config:', remoteConfig);
const remoteModule = await Promise.race([getRemote(remoteName, modulePathWithDot), timeoutPromise]);
const fetchEnd = performance.now();
console.log(`latency for remote module fetch: ${config.id}`, fetchEnd - fetchStart, 'ms');
console.log('[HOST] Remote module loaded, unwrapping...');
const unwrapStart = performance.now();
const mod = await unwrapModule(remoteModule);
const unwrapEnd = performance.now();
console.log(`latency for module unwrap: ${config.id}`, unwrapEnd - unwrapStart, 'ms');
console.log('[HOST] imported mod: ', mod);
console.log('[HOST] mod type: ', typeof mod);

const maybeFn =
typeof mod === 'function'
? mod
: ((mod as any)?.mount ??
(typeof (mod as any)?.default === 'function' ? (mod as any).default : (mod as any)?.default?.mount));

if (!aliveRef.current) {
console.error('[HOST] import/mount has failed due to timeout.');
return undefined;
}
if (!config.flags.enabled) {
console.warn(
'[HOST] skipping remote module loading for<config.id> because planning not to show it, enabled=false',
);
return undefined;
}

if (!containerElement) {
console.warn('[HOST] ref is null (container div not in DOM');
return undefined;
}

if (typeof maybeFn !== 'function') {
console.warn('MFE id: ', config.id, ' Mounting failed');
console.warn('[HOST] mount is not a function; got: ', maybeFn);
return undefined;
}
const mountFnStart = performance.now();
const cleanup = maybeFn(containerElement, {});
const mountFnEnd = performance.now();
console.log(`latency for mount function execution: ${config.id}`, mountFnEnd - mountFnStart, 'ms');
console.log('[HOST] mount called');
const mountEnd = performance.now();
const latency = mountEnd - mountStart;
console.log(`latency for successful MFE id: ${config.id}`, latency, 'ms');
return cleanup;
} catch (e) {
console.log(`latency for unsuccessful MFE id: ${config.id}`, performance.now() - mountStart, 'ms');
console.error('[HOST] import/mount failed:', e);
if (aliveRef.current) {
onError();
}
return undefined;
}
}

export const MFEBaseConfigurablePage = ({ config }: { config: MFEConfig }) => {
const isV2 = useIsThemeV2();
const isShowNavBarRedesign = useShowNavBarRedesign();
const box = useRef<HTMLDivElement>(null);
const history = useHistory();
const [hasError, setHasError] = useState(false);
const aliveRef = useRef(true);

useEffect(() => {
aliveRef.current = true;
let cleanup: (() => void) | undefined;

mountMFE({
config,
containerElement: box.current,
onError: () => setHasError(true),
aliveRef,
}).then((cleanupFn) => {
cleanup = cleanupFn;
});

return () => {
aliveRef.current = false;
if (cleanup) {
console.log('[HOST] Executing cleanup method provided by mount');
const cleanupStart = performance.now();
cleanup();
const cleanupEnd = performance.now();
console.log(`latency for cleanup execution: ${config.id}`, cleanupEnd - cleanupStart, 'ms');
}
};
}, [config, history]);

if (hasError) {
return <ErrorComponent isV2={isV2} message={`${config.label} is not available at this time`} />;
}
if (!config.flags.enabled) {
return <ErrorComponent isV2={isV2} message={`${config.label} is disabled.`} />;
}

return (
<MFEConfigurableContainer
isV2={isV2}
$isShowNavBarRedesign={isShowNavBarRedesign}
data-testid="mfe-configurable-container"
>
<div ref={box} style={{ minHeight: 480 }} />
</MFEConfigurableContainer>
);
};
Loading
Loading