Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React from 'react';

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

export const MFEBaseConfigurablePage = ({ config }: { config: MFEConfig }) => {
return <div>{config.label}</div>;
};
104 changes: 104 additions & 0 deletions datahub-web-react/src/app/mfeframework/READMEMFE.md
Original file line number Diff line number Diff line change
@@ -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.)_
Original file line number Diff line number Diff line change
@@ -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) => (
<div>
Route: {path} - {renderProp()}
</div>
),
}));
}

function mockMFEBasePage() {
vi.doMock('@app/mfeframework/MFEConfigurableContainer', () => ({
MFEBaseConfigurablePage: ({ config }: { config: any }) => <div>MFE: {config.module}</div>,
}));
}

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(<MFERoutes />);
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');
});
});
Loading
Loading