From 096ce81a0396287d1aad2bf16eb0804b49b1e89d Mon Sep 17 00:00:00 2001 From: "Nikolov, Philip (CCB, USA)" Date: Wed, 19 Nov 2025 14:52:25 -0500 Subject: [PATCH] DATAMGT-5231: mfe contribution --- datahub-web-react/package.json | 2 + datahub-web-react/src/app/SearchRoutes.tsx | 2 + .../navBarRedesign/NavBarMenuItemDropdown.tsx | 22 +- .../layout/navBarRedesign/NavSidebar.tsx | 32 ++ .../app/homeV2/layout/navBarRedesign/types.ts | 1 + .../src/app/mfeframework/ErrorComponent.tsx | 20 ++ .../mfeframework/MFEConfigurableContainer.tsx | 191 +++++++++++ .../_tests_/MFEConfigurableContainer.test.tsx | 176 ++++++++++ .../_tests_/mfeConfigLoader.test.tsx | 309 ++++++++++++++++++ .../_tests_/mfeNavBarMenuUtils.test.tsx | 158 +++++++++ .../src/app/mfeframework/federation.d.ts | 22 ++ .../src/app/mfeframework/mfeConfigLoader.tsx | 156 +++++++++ .../app/mfeframework/mfeNavBarMenuUtils.tsx | 92 ++++++ datahub-web-react/vite.config.ts | 27 ++ datahub-web-react/yarn.lock | 10 +- 15 files changed, 1217 insertions(+), 3 deletions(-) create mode 100644 datahub-web-react/src/app/mfeframework/ErrorComponent.tsx create mode 100644 datahub-web-react/src/app/mfeframework/MFEConfigurableContainer.tsx create mode 100644 datahub-web-react/src/app/mfeframework/_tests_/MFEConfigurableContainer.test.tsx create mode 100644 datahub-web-react/src/app/mfeframework/_tests_/mfeConfigLoader.test.tsx create mode 100644 datahub-web-react/src/app/mfeframework/_tests_/mfeNavBarMenuUtils.test.tsx create mode 100644 datahub-web-react/src/app/mfeframework/federation.d.ts create mode 100644 datahub-web-react/src/app/mfeframework/mfeConfigLoader.tsx create mode 100644 datahub-web-react/src/app/mfeframework/mfeNavBarMenuUtils.tsx diff --git a/datahub-web-react/package.json b/datahub-web-react/package.json index 7d2ce21ef42092..8bd4c31a608b19 100644 --- a/datahub-web-react/package.json +++ b/datahub-web-react/package.json @@ -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", @@ -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", diff --git a/datahub-web-react/src/app/SearchRoutes.tsx b/datahub-web-react/src/app/SearchRoutes.tsx index 14277aee79eff1..4cec82bde73751 100644 --- a/datahub-web-react/src/app/SearchRoutes.tsx +++ b/datahub-web-react/src/app/SearchRoutes.tsx @@ -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'; @@ -138,6 +139,7 @@ export const SearchRoutes = (): JSX.Element => { return ; }} /> + {me.loaded && loaded && } diff --git a/datahub-web-react/src/app/homeV2/layout/navBarRedesign/NavBarMenuItemDropdown.tsx b/datahub-web-react/src/app/homeV2/layout/navBarRedesign/NavBarMenuItemDropdown.tsx index 40ec212f4cdf9f..b45b221320ca19 100644 --- a/datahub-web-react/src/app/homeV2/layout/navBarRedesign/NavBarMenuItemDropdown.tsx +++ b/datahub-web-react/src/app/homeV2/layout/navBarRedesign/NavBarMenuItemDropdown.tsx @@ -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 }); @@ -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) @@ -65,7 +70,7 @@ export default function NavBarMenuItemDropdown({ item, isCollapsed, isSelected, { return ( - + {dropdownItems?.map((dropdownItem) => { return ( onItemClick(dropdownItem.key)} > - {dropdownItem.title} + {item.key === 'mfe-dropdown' ? ( + // Flex container for icon and title only for key "mfe" +
+ {dropdownItem.icon && ( + + {dropdownItem.icon} + + )} + {dropdownItem.title} +
+ ) : ( + // Default rendering for other items + {dropdownItem.title} + )} {dropdownItem.description} diff --git a/datahub-web-react/src/app/homeV2/layout/navBarRedesign/NavSidebar.tsx b/datahub-web-react/src/app/homeV2/layout/navBarRedesign/NavSidebar.tsx index 7b4c127da50b0b..ec52a6c7a1a55d 100644 --- a/datahub-web-react/src/app/homeV2/layout/navBarRedesign/NavSidebar.tsx +++ b/datahub-web-react/src/app/homeV2/layout/navBarRedesign/NavSidebar.tsx @@ -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'; @@ -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) { + mfeSection = [ + { + type: NavBarMenuItemTypes.Dropdown, + title: 'MFE Apps', + icon: , + 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(); @@ -151,6 +182,7 @@ export const NavSidebar = () => { onlyExactPathMapping: true, onClick: () => handleHomeclick(), }, + ...mfeSection, { type: NavBarMenuItemTypes.Group, key: 'govern', diff --git a/datahub-web-react/src/app/homeV2/layout/navBarRedesign/types.ts b/datahub-web-react/src/app/homeV2/layout/navBarRedesign/types.ts index 6fcd8329cea1cd..b4fce9edf72017 100644 --- a/datahub-web-react/src/app/homeV2/layout/navBarRedesign/types.ts +++ b/datahub-web-react/src/app/homeV2/layout/navBarRedesign/types.ts @@ -23,6 +23,7 @@ export interface NavBarMenuBaseElement { disabled?: boolean; href?: string; dataTestId?: string; + icon?: React.ReactNode; } export type Badge = { diff --git a/datahub-web-react/src/app/mfeframework/ErrorComponent.tsx b/datahub-web-react/src/app/mfeframework/ErrorComponent.tsx new file mode 100644 index 00000000000000..2cb9f42c928071 --- /dev/null +++ b/datahub-web-react/src/app/mfeframework/ErrorComponent.tsx @@ -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 {message}; +}; diff --git a/datahub-web-react/src/app/mfeframework/MFEConfigurableContainer.tsx b/datahub-web-react/src/app/mfeframework/MFEConfigurableContainer.tsx new file mode 100644 index 00000000000000..46fa93e3f8c81d --- /dev/null +++ b/datahub-web-react/src/app/mfeframework/MFEConfigurableContainer.tsx @@ -0,0 +1,191 @@ +import React, { useEffect, useRef, useState } from 'react'; +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('/'); + 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 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(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 ; + } + if (!config.flags.enabled) { + return ; + } + + return ( + +
+ + ); +}; diff --git a/datahub-web-react/src/app/mfeframework/_tests_/MFEConfigurableContainer.test.tsx b/datahub-web-react/src/app/mfeframework/_tests_/MFEConfigurableContainer.test.tsx new file mode 100644 index 00000000000000..de3db37d92b644 --- /dev/null +++ b/datahub-web-react/src/app/mfeframework/_tests_/MFEConfigurableContainer.test.tsx @@ -0,0 +1,176 @@ +import { act, render, screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { ThemeProvider } from 'styled-components'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MFEBaseConfigurablePage } from '@app/mfeframework/MFEConfigurableContainer'; +import * as themeHooks from '@app/useIsThemeV2'; +import * as navBarHooks from '@app/useShowNavBarRedesign'; + +// Mock theme and navbar hooks +vi.spyOn(themeHooks, 'useIsThemeV2').mockReturnValue(true); +vi.spyOn(navBarHooks, 'useShowNavBarRedesign').mockReturnValue(true); + +const validParsedYaml = { + microFrontends: [ + { + id: 'example-1', + label: 'Example MFE Yaml Item', + path: '/example-mfe-item', + remoteEntry: 'http://example.com/remoteEntry.js', + module: 'exampleApplication/mount', + flags: { enabled: true, showInNav: true }, + navIcon: 'Gear', + }, + { + id: 'myapp', + label: 'myapp from Yaml', + path: '/myapp-mfe', + remoteEntry: 'http://localhost:9111/remoteEntry.js', + module: 'myapp/mount', + flags: { enabled: true, showInNav: false }, + navIcon: 'Globe', + }, + ], +}; + +const sampleTheme = { + styles: { + 'border-radius-navbar-redesign': '16px', + 'box-shadow-navbar-redesign': '0 2px 8px rgba(0,0,0,0.15)', + }, + assets: {}, + content: {}, +}; + +// Mock useHistory +const pushMock = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useHistory: () => ({ push: pushMock }), + }; +}); + +// Mock federation methods +const { setRemoteMock } = vi.hoisted(() => ({ setRemoteMock: vi.fn() })); +const { getRemoteMock } = vi.hoisted(() => ({ getRemoteMock: vi.fn() })); +const { unwrapModuleMock } = vi.hoisted(() => ({ unwrapModuleMock: vi.fn() })); + +vi.mock('virtual:__federation__', () => ({ + __federation_method_getRemote: getRemoteMock, + __federation_method_setRemote: setRemoteMock, + __federation_method_unwrapDefault: unwrapModuleMock, +})); + +describe('MFEBaseConfigurablePage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the container div', () => { + const yaml = validParsedYaml.microFrontends[0]; + render( + + + + + , + ); + const container = screen.getByTestId('mfe-configurable-container'); + expect(container).toBeInTheDocument(); + }); + + it('calls mount on dynamic import', async () => { + const yaml = validParsedYaml.microFrontends[0]; + // Setup mocks for federation methods + const mountFn = vi.fn(() => vi.fn()); // returns cleanup function + getRemoteMock.mockResolvedValue({ mount: mountFn }); + unwrapModuleMock.mockResolvedValue({ mount: mountFn }); + + await act(async () => { + render( + + + + + , + ); + }); + const container = screen.getByTestId('mfe-configurable-container'); + expect(container).toBeInTheDocument(); + }); + + it('actually calls the mount function with the container and options', async () => { + const yaml = validParsedYaml.microFrontends[0]; + const mountFn = vi.fn(() => vi.fn()); + getRemoteMock.mockResolvedValue({ mount: mountFn }); + unwrapModuleMock.mockResolvedValue({ mount: mountFn }); + + await act(async () => { + render( + + + + + , + ); + }); + + const container = screen.getByTestId('mfe-configurable-container'); + const mountTarget = container.querySelector('div'); + expect(mountFn).toHaveBeenCalledWith(mountTarget, {}); + }); + + it('shows error UI when remote module times out', async () => { + const yaml = validParsedYaml.microFrontends[0]; + // Mock getRemote to never resolve + getRemoteMock.mockImplementation(() => new Promise(() => {})); + unwrapModuleMock.mockResolvedValue({}); + + vi.useFakeTimers(); + + render( + + + + + , + ); + + // Advance timers to trigger timeout + act(() => { + vi.advanceTimersByTime(5000); + }); + + // Wait for the error message to appear with a timeout + await vi.waitFor( + () => { + expect(screen.getByText(`${yaml.label} is not available at this time`)).toBeInTheDocument(); + }, + { timeout: 1000 }, + ); + + vi.useRealTimers(); + }, 10000); // Increase test timeout + + it('shows error UI when enabled flag is false', async () => { + const yaml = { + ...validParsedYaml.microFrontends[0], + flags: { enabled: false, showInNav: true }, + }; + + render( + + + + + , + ); + + // Check that the error message is displayed + expect(screen.getByText(`${yaml.label} is disabled.`)).toBeInTheDocument(); + }); +}); diff --git a/datahub-web-react/src/app/mfeframework/_tests_/mfeConfigLoader.test.tsx b/datahub-web-react/src/app/mfeframework/_tests_/mfeConfigLoader.test.tsx new file mode 100644 index 00000000000000..6d091041522114 --- /dev/null +++ b/datahub-web-react/src/app/mfeframework/_tests_/mfeConfigLoader.test.tsx @@ -0,0 +1,309 @@ +import { render, waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * Test Isolation and Mocking Strategy + * ----------------------------------- + * Each test in this file sets up its own mocks for YAML parsing and file imports + * using inline helper functions. This ensures: + * - Complete isolation between tests: no mock leakage or shared state. + * - Deterministic behavior: each test controls exactly what the YAML parser and file return. + * - No external file reads: all YAML content is mocked, never loaded from disk. + * - Flexible mocking: UI dependencies and config variations are easily handled per test. + * This approach keeps tests robust, maintainable, and focused on the intended scenario. + */ + +function mockYamlLoad(returnValue: any) { + vi.doMock('js-yaml', () => ({ + default: { load: vi.fn().mockReturnValue(returnValue) }, + })); +} + +function mockYamlLoadThrows(error: Error) { + vi.doMock('js-yaml', () => ({ + default: { + load: vi.fn().mockImplementation(() => { + throw error; + }), + }, + })); +} + +function mockReactRouter() { + vi.doMock('react-router', () => ({ + Route: ({ path, render: renderProp }: any) => ( +
+ Route: {path} - {renderProp()} +
+ ), + })); +} + +function mockMFEBasePage() { + vi.doMock('@app/mfeframework/MFEConfigurableContainer', () => ({ + MFEBaseConfigurablePage: ({ config }: { config: any }) =>
MFE: {config.module}
, + })); +} + +const validParsedYaml = { + subNavigationMode: false, + microFrontends: [ + { + id: 'example-1', + label: 'Example MFE Yaml Item', + path: '/example-mfe-item', + remoteEntry: 'http://example.com/remoteEntry.js', + module: 'exampleApplication/mount', + flags: { enabled: true, showInNav: true }, + navIcon: 'Gear', + }, + { + id: 'myapp', + label: 'myapp from Yaml', + path: '/myapp-mfe', + remoteEntry: 'http://localhost:9111/remoteEntry.js', + module: 'myapp/mount', + flags: { enabled: true, showInNav: false }, + navIcon: 'Globe', + }, + ], +}; + +describe('mfeConfigLoader', () => { + beforeEach(() => { + vi.resetModules(); + }); + + // Helper to mock fetch for YAML string + function mockFetchYaml(yamlString: string) { + global.fetch = vi.fn(() => + Promise.resolve( + new Response(yamlString, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }), + ), + ) as typeof global.fetch; + } + + it('loadMFEConfigFromYAML parses valid YAML and validates config', async () => { + mockYamlLoad(validParsedYaml); + const { loadMFEConfigFromYAML } = await import('../mfeConfigLoader'); + const result = loadMFEConfigFromYAML('irrelevant'); + expect(result.subNavigationMode).toBe(false); + expect(result.microFrontends.length).toBe(2); + expect(result.microFrontends[0]).toMatchObject(validParsedYaml.microFrontends[0]); + expect(result.microFrontends[1]).toMatchObject(validParsedYaml.microFrontends[1]); + }); + + it('loadMFEConfigFromYAML marks missing required fields as invalid and collects all errors', async () => { + mockYamlLoad({ + subNavigationMode: false, + microFrontends: [ + { + // id missing, navIcon missing + label: 'Missing ID and navIcon', + path: '/missing-id', + remoteEntry: 'remoteEntry.js', + module: 'MissingIdModule', + flags: { enabled: true, showInNav: false }, + }, + ], + }); + const { loadMFEConfigFromYAML } = await import('../mfeConfigLoader'); + const result = loadMFEConfigFromYAML('irrelevant'); + const mfe = result.microFrontends[0]; + if ('invalid' in mfe && mfe.invalid) { + expect(Array.isArray((mfe as any).errorMessages)).toBe(true); + expect((mfe as any).errorMessages).toEqual( + expect.arrayContaining([ + expect.stringContaining('Missing required field: id'), + expect.stringContaining('Missing required field: navIcon'), + ]), + ); + } + }); + + it('loadMFEConfigFromYAML marks multiple errors for a single MFE', async () => { + mockYamlLoad({ + subNavigationMode: false, + microFrontends: [ + { + id: 'bad-flags', + label: 'Bad Flags', + path: '/bad-flags', + remoteEntry: 'remoteEntry.js', + module: 123, // not a string + flags: { enabled: 'yes', showInNav: false }, // enabled not boolean + navIcon: 123, // not a string + }, + ], + }); + const { loadMFEConfigFromYAML } = await import('../mfeConfigLoader'); + const result = loadMFEConfigFromYAML('irrelevant'); + const mfe = result.microFrontends[0]; + if ('invalid' in mfe && mfe.invalid) { + expect(Array.isArray((mfe as any).errorMessages)).toBe(true); + expect((mfe as any).errorMessages).toEqual( + expect.arrayContaining([ + expect.stringContaining('module must be a string'), + expect.stringContaining('flags.enabled must be boolean'), + expect.stringContaining('navIcon must be a non-empty string'), + ]), + ); + } + }); + + it('loadMFEConfigFromYAML throws if microFrontends is missing', async () => { + mockYamlLoad({}); + const { loadMFEConfigFromYAML } = await import('../mfeConfigLoader'); + expect(() => loadMFEConfigFromYAML('irrelevant')).toThrow( + '[MFE Loader] Invalid YAML: missing microFrontends array', + ); + }); + + it('loadMFEConfigFromYAML throws if YAML parsing fails', async () => { + mockYamlLoadThrows(new Error('bad yaml')); + const { loadMFEConfigFromYAML } = await import('../mfeConfigLoader'); + expect(() => loadMFEConfigFromYAML('irrelevant')).toThrow('bad yaml'); + }); + + it('useMFEConfigFromBackend returns config if YAML is valid', async () => { + const yamlString = ` + subNavigationMode: false + microFrontends: + - id: example-1 + label: Example MFE Yaml Item + path: /example-mfe-item + remoteEntry: http://example.com/remoteEntry.js + module: exampleApplication/mount + flags: + enabled: true + showInNav: true + navIcon: Gear + `; + + mockFetchYaml(yamlString); + mockYamlLoad({ + subNavigationMode: false, + microFrontends: [ + { + id: 'example-1', + label: 'Example MFE Yaml Item', + path: '/example-mfe-item', + remoteEntry: 'http://example.com/remoteEntry.js', + module: 'exampleApplication/mount', + flags: { enabled: true, showInNav: true }, + navIcon: 'Gear', + }, + ], + }); + const { useMFEConfigFromBackend } = await import('../mfeConfigLoader'); + const { result } = renderHook(() => useMFEConfigFromBackend()); + await waitFor(() => { + expect(result.current?.microFrontends[0]).toMatchObject({ + id: 'example-1', + label: 'Example MFE Yaml Item', + path: '/example-mfe-item', + remoteEntry: 'http://example.com/remoteEntry.js', + module: 'exampleApplication/mount', + flags: { enabled: true, showInNav: true }, + navIcon: 'Gear', + }); + }); + }); + + it('useMFEConfigFromBackend returns null if Yaml is empty', async () => { + mockYamlLoad(null); + const { useMFEConfigFromBackend } = await import('../mfeConfigLoader'); + const { result } = renderHook(() => useMFEConfigFromBackend()); + expect(result.current).toBeNull(); + }); + + it('useMFEConfigFromBackend returns null if YAML is invalid', async () => { + mockYamlLoadThrows(new Error('bad yaml')); + const { useMFEConfigFromBackend } = await import('../mfeConfigLoader'); + const { result } = renderHook(() => useMFEConfigFromBackend()); + expect(result.current).toBeNull(); + }); + + it('useDynamicRoutes returns empty array if no config', async () => { + mockYamlLoad(null); + const { useDynamicRoutes } = await import('../mfeConfigLoader'); + const { result } = renderHook(() => useDynamicRoutes()); + expect(result.current).toEqual([]); + }); + + it('useDynamicRoutes returns Route elements for each MFE', async () => { + const yamlString = ` + subNavigationMode: false + microFrontends: + - id: example-1 + label: Example MFE Yaml Item + path: /example-mfe-item + remoteEntry: http://example.com/remoteEntry.js + module: exampleApplication/mount + flags: + enabled: true + showInNav: true + navIcon: Gear + - id: myapp + label: myapp from Yaml + path: /myapp-mfe + remoteEntry: http://localhost:9111/remoteEntry.js + module: myapp/mount + flags: + enabled: true + showInNav: false + navIcon: Globe + `; + mockFetchYaml(yamlString); + mockYamlLoad(validParsedYaml); + const { useDynamicRoutes } = await import('../mfeConfigLoader'); + const { result } = renderHook(() => useDynamicRoutes()); + await waitFor(() => { + expect(result.current).toHaveLength(2); + }); + expect(result.current[0].props.path).toBe('/mfe/example-mfe-item'); + expect(result.current[1].props.path).toBe('/mfe/myapp-mfe'); + }); + + it('MFERoutes renders the dynamic routes', async () => { + const yamlString = ` + subNavigationMode: false + microFrontends: + - id: example-1 + label: Example MFE Yaml Item + path: /example-mfe-item + remoteEntry: http://example.com/remoteEntry.js + module: exampleApplication/mount + flags: + enabled: true + showInNav: true + navIcon: Gear + - id: myapp + label: myapp from Yaml + path: /myapp-mfe + remoteEntry: http://localhost:9111/remoteEntry.js + module: myapp/mount + flags: + enabled: true + showInNav: false + navIcon: Globe + `; + mockFetchYaml(yamlString); + mockYamlLoad(validParsedYaml); + mockReactRouter(); + mockMFEBasePage(); + const { MFERoutes } = await import('../mfeConfigLoader'); + const { container } = render(); + await waitFor(() => { + expect(container.textContent).toContain('Route: /mfe/example-mfe-item'); + expect(container.textContent).toContain('Route: /mfe/myapp-mfe'); + expect(container.textContent).toContain('MFE: exampleApplication/mount'); + expect(container.textContent).toContain('MFE: myapp/mount'); + }); + }); +}); diff --git a/datahub-web-react/src/app/mfeframework/_tests_/mfeNavBarMenuUtils.test.tsx b/datahub-web-react/src/app/mfeframework/_tests_/mfeNavBarMenuUtils.test.tsx new file mode 100644 index 00000000000000..0afea69780aa0b --- /dev/null +++ b/datahub-web-react/src/app/mfeframework/_tests_/mfeNavBarMenuUtils.test.tsx @@ -0,0 +1,158 @@ +import { AppWindow, Archive } from '@phosphor-icons/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { NavBarMenuItemTypes } from '@app/homeV2/layout/navBarRedesign/types'; +import { MFESchema } from '@app/mfeframework/mfeConfigLoader'; +import { getMfeMenuDropdownItems, getMfeMenuItems } from '@app/mfeframework/mfeNavBarMenuUtils'; + +describe('getMfeMenuItems and getMfeMenuDropdownItems', () => { + it('returns only valid menu items where flags.showInNav is true and maps navIcon to correct icon', () => { + const mfeConfig: MFESchema = { + subNavigationMode: false, + microFrontends: [ + { + id: 'mfe-1', + label: 'MFE One', + path: '/mfe-one', + remoteEntry: 'https://mfe-one.com/remoteEntry.js', + module: 'mfeOneApplication/mount', + navIcon: 'Archive', + flags: { enabled: true, showInNav: true }, + }, + { + id: 'mfe-2', + label: 'MFE Two', + path: '/mfe-two', + remoteEntry: 'https://mfe-two.com/remoteEntry.js', + module: 'mfeTwoApplication/mount', + navIcon: 'Globe', + flags: { enabled: true, showInNav: false }, + }, + { + id: 'mfe-3', + label: 'MFE Three', + path: '/mfe-three', + remoteEntry: 'https://mfe-three.com/remoteEntry.js', + module: 'mfeThreeApplication/mount', + // navIcon not set, should default to AppWindow + navIcon: '', + flags: { enabled: true, showInNav: true }, + }, + { + id: 'mfe-4', + label: 'MFE Four', + path: '/mfe-four', + remoteEntry: 'https://mfe-four.com/remoteEntry.js', + module: 'mfeFourApplication/mount', + navIcon: 'InvalidNavIcon', + flags: { enabled: true, showInNav: true }, + }, + { + id: '', + label: '', + path: '', + remoteEntry: '', + module: '', + navIcon: '', + invalid: true, + errorMessages: ['Missing required field: id', 'navIcon must be a non-empty string'], + flags: { enabled: true, showInNav: true }, + }, + ], + }; + + const items = getMfeMenuItems(mfeConfig); + + expect(items).toHaveLength(3); + + // Check first item (should use Archive icon) + expect(items[0].type).toBe(NavBarMenuItemTypes.Item); + expect(items[0].title).toBe('MFE One'); + expect(items[0].key).toBe('mfe-1'); + expect(items[0].link).toBe('/mfe/mfe-one'); + expect(React.isValidElement(items[0].icon)).toBe(true); + expect(items[0].icon).not.toBeNull(); + expect((items[0].icon as JSX.Element).type).toBe(Archive); + expect(typeof items[0].onClick).toBe('function'); + + // Check second item (should default to AppWindow icon) + expect(items[1].type).toBe(NavBarMenuItemTypes.Item); + expect(items[1].title).toBe('MFE Three'); + expect(items[1].key).toBe('mfe-3'); + expect(items[1].link).toBe('/mfe/mfe-three'); + expect(React.isValidElement(items[1].icon)).toBe(true); + expect(items[1].icon).not.toBeNull(); + expect((items[1].icon as JSX.Element).type).toBe(AppWindow); + expect(typeof items[1].onClick).toBe('function'); + + // Check third item (should default to AppWindow icon as InvalidNavIcon is not a valid icon) + expect(items[2].type).toBe(NavBarMenuItemTypes.Item); + expect(items[2].title).toBe('MFE Four'); + expect(items[2].key).toBe('mfe-4'); + expect(items[2].link).toBe('/mfe/mfe-four'); + expect(React.isValidElement(items[2].icon)).toBe(true); + expect(items[2].icon).not.toBeNull(); + expect((items[2].icon as JSX.Element).type).toBe(AppWindow); + expect(typeof items[2].onClick).toBe('function'); + + // All should be of type Item + items.forEach((item) => { + expect(item.type).toBe(NavBarMenuItemTypes.Item); + }); + }); + + it('returns items of type DropdownElement when subNavigationMode is true', () => { + const mfeConfig: MFESchema = { + subNavigationMode: true, + microFrontends: [ + { + id: 'mfe-1', + label: 'MFE One', + path: '/mfe-one', + remoteEntry: 'https://mfe-one.com/remoteEntry.js', + module: 'mfeOneApplication/mount', + navIcon: 'Archive', + flags: { enabled: true, showInNav: true }, + }, + { + id: 'mfe-2', + label: 'MFE Two', + path: '/mfe-two', + remoteEntry: 'https://mfe-two.com/remoteEntry.js', + module: 'mfeTwoApplication/mount', + navIcon: 'Globe', + flags: { enabled: true, showInNav: true }, + }, + { + id: '', + label: '', + path: '', + remoteEntry: '', + module: '', + navIcon: '', + invalid: true, + errorMessages: ['Missing required field: id', 'navIcon must be a non-empty string'], + flags: { enabled: true, showInNav: true }, + }, + ], + }; + const items = getMfeMenuDropdownItems(mfeConfig); + expect(items.length).toBe(2); + items.forEach((item) => { + expect(item.type).toBe(NavBarMenuItemTypes.DropdownElement); + }); + }); + + it('returns empty array if no microFrontends (getMfeMenuItems)', () => { + const mfeConfig: MFESchema = { microFrontends: [], subNavigationMode: false }; + const items = getMfeMenuItems(mfeConfig); + expect(items).toEqual([]); + }); + + it('returns empty array if no microFrontends (getMfeMenuDropdownItems)', () => { + const mfeConfig: MFESchema = { microFrontends: [], subNavigationMode: true }; + const items = getMfeMenuDropdownItems(mfeConfig); + expect(items).toEqual([]); + }); +}); diff --git a/datahub-web-react/src/app/mfeframework/federation.d.ts b/datahub-web-react/src/app/mfeframework/federation.d.ts new file mode 100644 index 00000000000000..14b22230ced48c --- /dev/null +++ b/datahub-web-react/src/app/mfeframework/federation.d.ts @@ -0,0 +1,22 @@ +declare module '*/*' { + export function mount(e1: HTMLElement, props?: Record): () => void; +} + +/* eslint-disable @typescript-eslint/naming-convention */ +declare module 'virtual:__federation__' { + interface IRemoteConfig { + url: (() => Promise) | string; + format: 'esm' | 'systemjs' | 'var'; + from: 'vite' | 'webpack'; + } + + export function __federation_method_setRemote(name: string, config: IRemoteConfig): void; + + export function __federation_method_getRemote(name: string, exposedPath: string): Promise; + + export function __federation_method_unwrapDefault(unwrappedModule: unknown): Promise; + + export function __federation_method_ensure(remoteName: string): Promise; + + export function __federation_method_wrapDefault(module: unknown, need: boolean): Promise; +} diff --git a/datahub-web-react/src/app/mfeframework/mfeConfigLoader.tsx b/datahub-web-react/src/app/mfeframework/mfeConfigLoader.tsx new file mode 100644 index 00000000000000..64882240a27992 --- /dev/null +++ b/datahub-web-react/src/app/mfeframework/mfeConfigLoader.tsx @@ -0,0 +1,156 @@ +import yaml from 'js-yaml'; +import React, { useEffect, useState } from 'react'; +import { Route } from 'react-router'; + +import { MFEBaseConfigurablePage } from '@app/mfeframework/MFEConfigurableContainer'; + +export interface MFEFlags { + enabled: boolean; + showInNav: boolean; +} + +// MFEConfig: Type for a valid micro frontend config entry. +export interface MFEConfig { + id: string; + label: string; + path: string; + remoteEntry: string; + module: string; + flags: MFEFlags; + navIcon: string; +} + +/** + * InvalidMFEConfig: Type for an invalid micro frontend config entry. + * - invalid: true + * - errorMessages: array of validation errors for this entry + * - id: optional, for easier debugging/logging + * - [key: string]: any; allows for partial/invalid configs + */ +export interface InvalidMFEConfig { + invalid: true; + errorMessages: string[]; + id?: string; + [key: string]: any; +} + +// MFEConfigEntry: Union type for either a valid or invalid config entry. +export type MFEConfigEntry = MFEConfig | InvalidMFEConfig; + +// MFESchema: The overall config schema, with a union array for microFrontends. +export interface MFESchema { + subNavigationMode: boolean; + microFrontends: MFEConfigEntry[]; +} + +const REQUIRED_FIELDS: (keyof MFEConfig)[] = ['id', 'label', 'path', 'remoteEntry', 'module', 'flags', 'navIcon']; + +/** + * validateMFEConfig: + * - Validates a single micro frontend config entry. + * - Collects all validation errors for the entry. + * - Returns a valid MFEConfig if no errors, otherwise returns InvalidMFEConfig with all error messages. + * - This allows the loader to keep all entries (valid and invalid) and not throw on the first error. + */ +export function validateMFEConfig(config: any): MFEConfigEntry { + const errors: string[] = []; + + REQUIRED_FIELDS.forEach((field) => { + if (config[field] === undefined || config[field] === null) { + errors.push(`[MFE Loader] Missing required field: ${field}`); + } + }); + if (typeof config.id !== 'string') errors.push('[MFE Loader] id must be a string'); + if (typeof config.label !== 'string') errors.push('[MFE Loader] label must be a string'); + if (typeof config.path !== 'string' || !config.path.startsWith('/')) + errors.push('[MFE Loader] path must be a string starting with "/"'); + if (typeof config.remoteEntry !== 'string') errors.push('[MFE Loader] remoteEntry must be a string'); + if (typeof config.module !== 'string') errors.push('[MFE Loader] module must be a string'); + if (typeof config.flags !== 'object' || config.flags === null) errors.push('[MFE Loader] flags must be an object'); + if (config.flags) { + if (typeof config.flags.enabled !== 'boolean') errors.push('[MFE Loader] flags.enabled must be boolean'); + if (typeof config.flags.showInNav !== 'boolean') errors.push('[MFE Loader] flags.showInNav must be boolean'); + } + if (typeof config.navIcon !== 'string' || !config.navIcon.length) { + errors.push('[MFE Loader] navIcon must be a non-empty string'); + } + + // If any errors, return as InvalidMFEConfig (with all errors collected) + if (errors.length > 0) { + return { + ...config, + invalid: true, + errorMessages: errors, + }; + } + // Otherwise, return as valid MFEConfig + return config as MFEConfig; +} + +/** + * loadMFEConfigFromYAML: + * - Loads and parses the YAML config string. + * - Validates each micro frontend entry, collecting errors but not throwing for individual entries. + * - Returns the parsed schema with both valid and invalid entries. + * - Throws only if the overall YAML is malformed or missing the microFrontends array. + */ +export function loadMFEConfigFromYAML(yamlString: string): MFESchema { + try { + console.log('[MFE Loader] Raw YAML:', yamlString); + const parsed = yaml.load(yamlString) as MFESchema; + // console.log('[MFE Loader] Parsed YAML config:', parsed); + if (!parsed || !Array.isArray(parsed.microFrontends)) { + console.error('[MFE Loader] Invalid YAML: missing microFrontends array:', parsed); + throw new Error('[MFE Loader] Invalid YAML: missing microFrontends array'); + } + // Validate each entry, keeping both valid and invalid ones + parsed.microFrontends = parsed.microFrontends.map(validateMFEConfig); + return parsed; + } catch (e) { + console.error('[MFE Loader] Error parsing YAML:', e); + throw e; + } +} + +export function useMFEConfigFromBackend(): MFESchema | null { + const [config, setConfig] = useState(null); + + useEffect(() => { + async function fetchConfig() { + try { + const response = await fetch('/api/mfe/config'); + if (!response.ok) throw new Error(`Failed to fetch YAML: ${response.statusText}`); + const yamlText = await response.text(); + + console.log('[MFE Loader] Fetched YAML: ', yamlText); + const parsedConfig = loadMFEConfigFromYAML(yamlText); + setConfig(parsedConfig); + } catch (e) { + console.error('[MFE Loader] Config error:', e); + setConfig(null); + } + } + fetchConfig(); + }, []); + + return config; +} + +export function useDynamicRoutes(): JSX.Element[] { + const mfeConfig = useMFEConfigFromBackend(); + if (!mfeConfig) return []; + // TODO- Reintroduce useMemo() hook here. Make it work with getting yaml from api as a react hook. + const isValidMFEConfig = (entry: MFEConfigEntry): entry is MFEConfig => !('invalid' in entry && entry.invalid); + return mfeConfig.microFrontends + .filter(isValidMFEConfig) + .map((mfe) => ( + } /> + )); +} + +// Constant to store the dynamic routes hook +export const MFERoutes = () => { + const routes = useDynamicRoutes(); + console.log('[DynamicRoute] Generated Routes:', routes); + return <>{routes}; +}; diff --git a/datahub-web-react/src/app/mfeframework/mfeNavBarMenuUtils.tsx b/datahub-web-react/src/app/mfeframework/mfeNavBarMenuUtils.tsx new file mode 100644 index 00000000000000..3dfa0e9ebc6a9a --- /dev/null +++ b/datahub-web-react/src/app/mfeframework/mfeNavBarMenuUtils.tsx @@ -0,0 +1,92 @@ +import * as PhosphorIcons from '@phosphor-icons/react'; +import { AppWindow } from '@phosphor-icons/react'; +import React from 'react'; + +import { + NavBarMenuDropdownItemElement, + NavBarMenuItemTypes, + NavBarMenuLinkItem, +} from '@app/homeV2/layout/navBarRedesign/types'; +import { MFEConfig, MFEConfigEntry, MFESchema } from '@app/mfeframework/mfeConfigLoader'; + +/* Converts any phosphor icon string into a phosphor JSX.Element, if iconString is undefined or not a valid icon, default is . + eg: Tag wil become */ +function getPhosphorIconElement(iconString?: string): JSX.Element { + const Component = iconString && PhosphorIcons[iconString]; + return Component ? : ; +} + +// Type guard to check if an entry is a valid MFEConfig. +function isValidMFEConfig(mfe: MFEConfigEntry): mfe is MFEConfig { + return !('invalid' in mfe && mfe.invalid); +} + +// Set to track which error messages have already been logged +const loggedInvalidMfeIssues = new Set(); + +function logInvalidMfeIssues(mfe: MFEConfigEntry) { + const id = 'id' in mfe && mfe.id ? mfe.id : 'unknown'; + if ('invalid' in mfe && mfe.invalid && Array.isArray((mfe as any).errorMessages)) { + (mfe as any).errorMessages.forEach((msg: string) => { + const uniqueKey = `${id}:${msg}`; + if (!loggedInvalidMfeIssues.has(uniqueKey)) { + console.error(`[MFE Nav] Invalid MFE config for id: ${id} - ${msg}`); + loggedInvalidMfeIssues.add(uniqueKey); + } + }); + } +} + +/** + * Returns menu items for MFEs when subNavigationMode is FALSE. + * Only valid MFEs (not marked as invalid) and with showInNav=true are included. + * Logs console errors for invalid MFEs. + */ +export function getMfeMenuItems(mfeConfig: MFESchema): NavBarMenuLinkItem[] { + if (!mfeConfig?.microFrontends) return []; + + mfeConfig.microFrontends.forEach((mfe) => { + logInvalidMfeIssues(mfe); + }); + + return mfeConfig.microFrontends + .filter(isValidMFEConfig) + .filter((mfe) => mfe.flags?.showInNav) + .map((mfe) => ({ + type: NavBarMenuItemTypes.Item, + title: mfe.label, + key: mfe.id, + icon: getPhosphorIconElement(mfe.navIcon), + link: `/mfe${mfe.path}`, + onClick: () => { + console.log(`[MFE Nav] Clicked MFE nav item: ${mfe.label}, path: ${mfe.path}`); + }, + })); +} + +/** + * Returns dropdown menu items for MFEs when subNavigationMode is TRUE. + * Only valid MFEs (not marked as invalid) and with showInNav=true are included. + * Logs console errors for invalid MFEs. + */ +export function getMfeMenuDropdownItems(mfeConfig: MFESchema): NavBarMenuDropdownItemElement[] { + if (!mfeConfig?.microFrontends) return []; + + mfeConfig.microFrontends.forEach((mfe) => { + logInvalidMfeIssues(mfe); + }); + + return mfeConfig.microFrontends + .filter(isValidMFEConfig) + .filter((mfe) => mfe.flags?.showInNav) + .map((mfe) => ({ + type: NavBarMenuItemTypes.DropdownElement, + title: mfe.label, + key: mfe.id, + icon: getPhosphorIconElement(mfe.navIcon), + link: `/mfe${mfe.path}`, + onClick: () => { + console.log(`[MFE Nav] Clicked MFE nav item: ${mfe.label}, path: ${mfe.path}`); + }, + })); +} diff --git a/datahub-web-react/vite.config.ts b/datahub-web-react/vite.config.ts index f6e1ed18c55d76..092dd31fe08827 100644 --- a/datahub-web-react/vite.config.ts +++ b/datahub-web-react/vite.config.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import { defineConfig, loadEnv } from 'vite'; import macrosPlugin from 'vite-plugin-babel-macros'; import svgr from 'vite-plugin-svgr'; +import federation from '@originjs/vite-plugin-federation'; const injectMeticulous = () => { if (!process.env.REACT_APP_METICULOUS_PROJECT_TOKEN) { @@ -53,17 +54,36 @@ export default defineConfig(async ({ mode }) => { antThemeConfig = require(themeConfigFile); } + // common extra logging setup for proxies + const proxyDebugConfig = (proxy, options) => { + proxy.on('proxyReq', (proxyReq, req, _res) => { + console.log(`[PROXY] ${req.url} -> ${options.target}${req.url}`); + }); + proxy.on('proxyRes', (proxyRes, req, _res) => { + console.log(`[PROXY RESPONSE] ${req.url} <- ${proxyRes.statusCode} ${proxyRes.statusMessage}`); + if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400) { + console.log(`[PROXY REDIRECT] Location: ${proxyRes.headers.location}`); + } + }); + proxy.on('error', (err, req, _res) => { + console.error(`[PROXY ERROR] ${req.url}:`, err.message); + }); + }; + // Setup proxy to the datahub-frontend service. const frontendProxy = { target: process.env.REACT_APP_PROXY_TARGET || 'http://localhost:9002', changeOrigin: true, + configure: proxyDebugConfig, }; + const proxyOptions = { '/logIn': frontendProxy, '/authenticate': frontendProxy, '/api/v2/graphql': frontendProxy, '/openapi/v1/tracking/track': frontendProxy, '/openapi/v1/files': frontendProxy, + '/api/mfe/config': frontendProxy, }; const devPlugins = mode === 'development' ? [injectMeticulous()] : []; @@ -74,6 +94,13 @@ export default defineConfig(async ({ mode }) => { plugins: [ ...devPlugins, react(), + federation({ + name: "datahub-host", + remotes: { + // at least one remote is needed to load the plugin correctly, just remotes: {} does not work + remoteName: '', + }, + }), svgr(), macrosPlugin(), viteStaticCopy({ diff --git a/datahub-web-react/yarn.lock b/datahub-web-react/yarn.lock index fad009df2f5a54..114dfd1ba8608b 100644 --- a/datahub-web-react/yarn.lock +++ b/datahub-web-react/yarn.lock @@ -2596,6 +2596,14 @@ dependencies: "@octokit/openapi-types" "^24.2.0" +"@originjs/vite-plugin-federation@^1.4.1": + version "1.4.1" + resolved "https://registry.npmjs.org/@originjs/vite-plugin-federation/-/vite-plugin-federation-1.4.1.tgz#e6abc8f18f2cf82783eb87853f4d03e6358b43c2" + integrity sha512-Uo08jW5pj1t58OUKuZNkmzcfTN2pqeVuAWCCiKf/75/oll4Efq4cHOqSE1FXMlvwZNGDziNdDyBbQ5IANem3CQ== + dependencies: + estree-walker "^3.0.2" + magic-string "^0.27.0" + "@peculiar/asn1-schema@^2.3.6": version "2.3.8" resolved "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz" @@ -8054,7 +8062,7 @@ estree-walker@^2.0.2: resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz" integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== -estree-walker@^3.0.3: +estree-walker@^3.0.2, estree-walker@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz" integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==