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..6fe0be270901ce --- /dev/null +++ b/datahub-web-react/src/app/mfeframework/MFEConfigurableContainer.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +import { MFEConfig } from '@app/mfeframework/mfeConfigLoader'; + +export const MFEBaseConfigurablePage = ({ config }: { config: MFEConfig }) => { + return
{config.label}
; +}; diff --git a/datahub-web-react/src/app/mfeframework/READMEMFE.md b/datahub-web-react/src/app/mfeframework/READMEMFE.md new file mode 100644 index 00000000000000..af4b726790b58c --- /dev/null +++ b/datahub-web-react/src/app/mfeframework/READMEMFE.md @@ -0,0 +1,104 @@ +# Micro Frontend (MFE) Onboarding & Contributor Guide + +## Introduction + +Scalable onboarding of Micro Frontends (MFEs) in DataHub is achieved using a validated configuration schema. +MFEs are declared in a canonical YAML file, validated at runtime, and dynamically integrated into the application’s navigation. +With this approach, you can add, update, or remove MFEs by configuration only—**no manual code changes required**. + +## Setup & Usage + +1. **See `datahub-web-react/src/app/mfeframework` for all relevant code and configuration.** +2. **Declare MFEs in `mfe.config.yaml` using the schema below.** +3. **Navigation items are automatically added based on your YAML settings—no manual coding needed\*** +4. **Run locally:** Point `remoteEntry` to your local dev server. +5. **Deploy:** No code changes required for new MFEs—just update the config! --> (`mfe.config.yaml`) + +## Validation & Troubleshooting + +- **Validation logic** `mfeConfigLoader.tsx` handles validation of `mfe.config.yaml` + - Invalid entries are marked with `invalid: true` and an array of `errorMessages`, and are skipped in navigation and routing. + - **Missing required fields:** Loader will log which fields are missing. + - **Invalid types:** Loader will log type errors (e.g., non-boolean flags). + - **No config found:** Loader will warn if the YAML file is missing or empty. + +**Example errors:** + +``` +[MFE Loader] flags.showInNav must be boolean +[MFE Loader] flags must be an object +[MFE Loader] path must be a string starting with "/" +``` + +## Features + +- **Config-Driven Onboarding:** Add MFEs by editing a YAML file. +- **Validation:** Ensures all required fields are present and correctly typed. +- **Dynamic Navigation:** Automatically generates navigation items for enabled MFEs. +- **Robust Error Handling:** Logs clear error messages for missing or invalid configuration. + +## Configuration Schema + +Declare your MFEs in a YAML file (`mfe.config.yaml`) using the following example structure: + +The following schema defines all micro frontends available in the application. +Each field is **mandatory** and must conform to the specified type. + +- **subNavigationMode:** boolean (true/false) +- **microFrontends:** list of MFE objects (see below) + +Each MFE object (microFrontends) must include: + +- **id:** string (unique identifier for the MFE) +- **label:** string (display name in navigation) +- **path:** string (route path, must start with '/') +- **remoteEntry:** string (URL to the remoteEntry.js file) +- **module:** string (module name to mount, e.g. 'appName/mount') +- **flags:** object (see below) + - **enabled:** boolean (true to enable, false to disable) + - **showInNav:** boolean (true to show in navigation) +- **permissions:** array of strings (required permissions for access) +- **navIcon:** string (icon name from "@phosphor-icons/react", e.g. 'Gear') + +**Example icon names:** Gear, Globe, Acorn, Airplane, Alarm +**See: https://github.com/phosphor-icons/react/tree/master/src/csr** + +```yaml +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 + permissions: + - example_permission + navIcon: Gear +``` + +## Dynamic Navigation Integration + +Sidebar navigation items are generated dynamically from the validated MFE config. +You may toggle between using Sub Navigation or not: + +- See above schema for: **subNavigationMode:** boolean (true/false) + - If subNavigationMode is enabled, the navigation bar will render each MFE into a scrollable list + - If subNavigationMode is disabled, each MFE will appear in navigation bar in order of declaration of yaml + +## Common Pitfalls + +- **All required fields must be present and valid.** +- **Path must start with `/`.** +- **RemoteEntry must be accessible.** +- **Icon names must be valid or provide a valid image path.** +- **Permissions array must not be empty.** +- **Local server must be running for local development.** +- **Invalid entries are logged and skipped.** + +## Next Steps + +- **Deployment:** No code changes required for new MFEs—just update the config! + _(Production deployment steps are still being finalized. Please check back for updates.)_ 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..f614de5420f7aa --- /dev/null +++ b/datahub-web-react/src/app/mfeframework/_tests_/mfeConfigLoader.test.tsx @@ -0,0 +1,271 @@ +import { render } 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 mockYamlFile(content: string) { + vi.doMock('@app/mfeframework/mfe.config.yaml?raw', () => ({ default: content })); +} + +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 }, + permissions: ['example_permission'], + navIcon: 'Gear', + }, + { + id: 'another', + label: 'Another from Yaml', + path: '/another-mfe', + remoteEntry: 'http://localhost:9111/remoteEntry.js', + module: 'anotherRemoteModule/mount', + flags: { enabled: true, showInNav: false }, + permissions: ['example_permission'], + navIcon: 'Globe', + }, + ], +}; + +describe('mfeConfigLoader', () => { + beforeEach(() => { + vi.resetModules(); + // Default YAML file mock for most tests; override in test if needed + mockYamlFile('irrelevant'); + }); + + 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 }, + permissions: ['perm1'], + }, + ], + }); + 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 + permissions: 'perm1', // not array + 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('permissions must be a non-empty array of strings'), + expect.stringContaining('navIcon must be a non-empty string'), + ]), + ); + } + }); + + it('loadMFEConfigFromYAML marks empty permissions as invalid', async () => { + mockYamlLoad({ + subNavigationMode: false, + microFrontends: [ + { + id: 'empty-permissions', + label: 'Empty Permissions', + path: '/empty-permissions', + remoteEntry: 'remoteEntry.js', + module: 'EmptyPermissionsModule', + flags: { enabled: true, showInNav: false }, + permissions: [], + navIcon: 'Gear', + }, + ], + }); + 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('permissions must be a non-empty array of strings')]), + ); + } + }); + + 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('useMFEConfigFromYAML returns config if YAML is valid', async () => { + 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 }, + permissions: ['example_permission'], + navIcon: 'Gear', + }, + ], + }); + const { useMFEConfigFromYAML } = await import('../mfeConfigLoader'); + const { result } = renderHook(() => useMFEConfigFromYAML()); + 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 }, + permissions: ['example_permission'], + navIcon: 'Gear', + }); + }); + + it('useMFEConfigFromYAML returns null if mfeYamlRaw is empty', async () => { + mockYamlFile(''); + mockYamlLoad(null); + const { useMFEConfigFromYAML } = await import('../mfeConfigLoader'); + const { result } = renderHook(() => useMFEConfigFromYAML()); + expect(result.current).toBeNull(); + }); + + it('useMFEConfigFromYAML returns null if YAML is invalid', async () => { + mockYamlLoadThrows(new Error('bad yaml')); + const { useMFEConfigFromYAML } = await import('../mfeConfigLoader'); + const { result } = renderHook(() => useMFEConfigFromYAML()); + 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 () => { + mockYamlLoad(validParsedYaml); + const { useDynamicRoutes } = await import('../mfeConfigLoader'); + const { result } = renderHook(() => useDynamicRoutes()); + expect(result.current).toHaveLength(2); + expect(result.current[0].props.path).toBe('/example-mfe-item'); + expect(result.current[1].props.path).toBe('/another-mfe'); + }); + + it('MFERoutes renders the dynamic routes', async () => { + mockYamlLoad(validParsedYaml); + mockReactRouter(); + mockMFEBasePage(); + const { MFERoutes } = await import('../mfeConfigLoader'); + const { container } = render(); + expect(container.textContent).toContain('Route: /example-mfe-item'); + expect(container.textContent).toContain('Route: /another-mfe'); + expect(container.textContent).toContain('MFE: exampleApplication/mount'); + expect(container.textContent).toContain('MFE: anotherRemoteModule/mount'); + }); +}); diff --git a/datahub-web-react/src/app/mfeframework/mfe.config.yaml b/datahub-web-react/src/app/mfeframework/mfe.config.yaml new file mode 100644 index 00000000000000..e780782575d777 --- /dev/null +++ b/datahub-web-react/src/app/mfeframework/mfe.config.yaml @@ -0,0 +1,17 @@ +subNavigationMode: false +microFrontends: [] +# # sample entry, uncomment and modify as needed +# - id: example-1 +# label: Example MFE Yaml Item +# path: /example-mfe-item +# remoteEntry: http://example.com/remoteEntry.js +# module: exampleApplication/mount +# #flags is enforced to be a list with two boolean entries +# flags: +# enabled: true +# showInNav: true +# # permissions is enforced to be an array +# permissions: +# - example_permission +# # navIcon value is name of any icon in "@phosphor-icons/react" that you decide to use for your mfe, can be found in https://github.com/phosphor-icons/react/tree/master/src/csr +# navIcon: Gear 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..7873f43e2180e8 --- /dev/null +++ b/datahub-web-react/src/app/mfeframework/mfeConfigLoader.tsx @@ -0,0 +1,179 @@ +import yaml from 'js-yaml'; +import React, { useMemo } from 'react'; +import { Route } from 'react-router'; + +import { MFEBaseConfigurablePage } from '@app/mfeframework/MFEConfigurableContainer'; +// Vite's ?raw import lets you import the YAML file as a string +import mfeYamlRaw from '@app/mfeframework/mfe.config.yaml?raw'; + +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; + permissions: string[]; + 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[]; +} + +// SPECIAL NOTE: 'permissions' will be implemented in later sprints. Keeping it as required and subsequently validated so we do not forget. +const REQUIRED_FIELDS: (keyof MFEConfig)[] = [ + 'id', + 'label', + 'path', + 'remoteEntry', + 'module', + 'flags', + 'permissions', + '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 ( + !Array.isArray(config.permissions) || + config.permissions.length === 0 || + !config.permissions.every((p) => typeof p === 'string') + ) { + errors.push('[MFE Loader] permissions must be a non-empty array of strings'); + } + 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 useMFEConfigFromYAML(): MFESchema | null { + return useMemo(() => { + try { + if (!mfeYamlRaw) { + console.warn('[MFE Loader] No YAML config found'); + return null; + } + const config = loadMFEConfigFromYAML(mfeYamlRaw); + if (config) { + console.log('[MFE Loader] useMFEConfigFromYAML loaded:', config); + } + return config; + } catch (e) { + console.error('[MFE Loader] Config error:', e); + return null; // <-- Return null so the app can continue rendering + } + }, []); +} + +export function useDynamicRoutes(): JSX.Element[] { + const mfeConfig = useMFEConfigFromYAML(); + + // Type guard to narrow MFEConfigEntry to MFEConfig + const isValidMFEConfig = (entry: MFEConfigEntry): entry is MFEConfig => !('invalid' in entry && entry.invalid); + + return useMemo(() => { + if (!mfeConfig) { + console.warn('[DynamicRoute] No MFE config available'); + return []; + } + console.log('[DynamicRoute] MFE Config:', mfeConfig); + return mfeConfig.microFrontends + .filter(isValidMFEConfig) + .map((mfe) => ( + } /> + )); + }, [mfeConfig]); +} + +// Constant to store the dynamic routes hook +export const MFERoutes = () => { + const routes = useDynamicRoutes(); + console.log('[DynamicRoute] Generated Routes:', routes); + return <>{routes}; +};