Skip to content

Commit 46ff57d

Browse files
committed
refactor: module system
1 parent c8fe5e1 commit 46ff57d

File tree

11 files changed

+340
-363
lines changed

11 files changed

+340
-363
lines changed

frontend_omni/architecture/core.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,24 +49,24 @@ Without any modules, the frontend would be completely empty just containing an e
4949

5050
## 4. Module System
5151

52-
The heart of the frontend is its modular system. It is defined in the `ModuleManager` interface as:
52+
The heart of the frontend is its modular system. It is defined in the `Modules` interface as:
5353

5454
```typescript
5555
getOne<T>(type: string): T | null;
5656
getAll<T>(type: string): T[];
5757
```
5858

59-
### 4.1 Getting the `ModuleManager`
59+
### 4.1 Getting the `Modules`
6060

61-
In order to use the functions of the module manager, it must be somehow received first. This is done with hooks like this:
61+
In order to use the functions of the modules, it must be somehow received first. This is done with hooks like this:
6262

6363
```typescript
6464
const modules = useModules();
6565

6666
modules.getAll<SomeComponent>(...)
6767
```
6868

69-
### 4.2 Using the `ModuleManager`
69+
### 4.2 Using the `Modules`
7070

7171
The most important functionality of the module system is to receive other modules by their type. This is needed if a parent module wants to receive its child modules for rendering or if a user component needs to get the user service for backend interaction.
7272

@@ -89,7 +89,7 @@ Modules needs to be registered in the `src/modules/moduleRegistry.ts`.
8989

9090
To also activate a module, it needs to be added to the `modules*.json`:
9191

92-
The registration of modules in the ModuleManager is not defined by the `ModuleManager` interface. The default implementation handles module registration with a `modules*.json` file (`modules_with_backend.json` and `modules_browser_only.json`; the two files are used to startup different versions a full and lite version) loaded at startup of the application. The json has the following structure:
92+
The registration of modules in the Modules is not defined by the `Modules` interface. The default implementation handles module registration with a `modules*.json` file (`modules_with_backend.json` and `modules_browser_only.json`; the two files are used to startup different versions a full and lite version) loaded at startup of the application. The json has the following structure:
9393

9494
```json
9595
{

frontend_omni/src/modules/main-layout/App.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
Routes,
99
} from "react-router-dom";
1010
import { useModules } from "@/modules/module-system";
11-
import { ModuleManagerProvider } from "@/modules/module-system/ModuleManagerContext";
11+
import { ModulesProvider } from "@/modules/module-system/ModulesContext";
1212
import { ThemeProvider } from "@/modules/theme-provider/ThemeProvider";
1313
import { SidebarProvider } from "@/shadcn/components/ui/sidebar";
1414
import { Toaster } from "@/shadcn/components/ui/sonner";
@@ -25,11 +25,11 @@ export default function App() {
2525
<ThemeProvider>
2626
<QueryClientProvider client={queryClient}>
2727
<Suspense fallback={<PageLoadingScreen />}>
28-
<ModuleManagerProvider>
28+
<ModulesProvider>
2929
<ModuleContextProvider name="GlobalContextProvider">
3030
<RoutedSidebarLayout />
3131
</ModuleContextProvider>
32-
</ModuleManagerProvider>
32+
</ModulesProvider>
3333
</Suspense>
3434
<Toaster />
3535
</QueryClientProvider>

frontend_omni/src/modules/module-system/ModuleManagerContext.tsx

Lines changed: 0 additions & 37 deletions
This file was deleted.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { useSuspenseQuery } from "@tanstack/react-query";
2+
import { type ReactNode, useMemo } from "react";
3+
import { ModulesContext } from ".";
4+
import { activateModules } from "./moduleActivator";
5+
import { LoadedModule, ModuleRegistry } from "./moduleRegistry";
6+
import { fetchModulesJsonAsync } from "./modulesJson";
7+
8+
const modulesJsonPath = "/modules.json";
9+
10+
interface ModulesProviderProps {
11+
children: ReactNode;
12+
}
13+
14+
export function ModulesProvider({ children }: ModulesProviderProps) {
15+
const { data: modulesJson, error } = useSuspenseQuery({
16+
queryKey: ["modulesJson", modulesJsonPath],
17+
queryFn: () => fetchModulesJsonAsync(modulesJsonPath),
18+
});
19+
20+
if (error) {
21+
throw new Error(`Error loading module modules json: ${error.message}`);
22+
}
23+
24+
if (!modulesJson) {
25+
throw new Error("Module json data is undefined");
26+
}
27+
28+
const activeModules = useMemo(() => {
29+
const moduleRegistry = new ModuleRegistry(modulesJson.modules);
30+
const activeModules = activateModules(moduleRegistry);
31+
return new ActiveModules(activeModules);
32+
}, [modulesJson]);
33+
34+
return <ModulesContext value={activeModules}>{children}</ModulesContext>;
35+
}
36+
37+
class ActiveModules {
38+
private activeModules: Map<string, LoadedModule>;
39+
40+
constructor(activeModules: LoadedModule[]) {
41+
this.activeModules = new Map(activeModules.map((m) => [m.id, m]));
42+
}
43+
44+
/**
45+
* Get a single component of a specific name across all modules.
46+
* If more than one component with the same name exists, returns null.
47+
*/
48+
getOne<T>(name: string): T | null {
49+
const elements = this.getAll<T>(name);
50+
51+
if (elements.length > 1) {
52+
console.warn(
53+
`Multiple components found with name ${name}, returning null.`,
54+
);
55+
return null;
56+
}
57+
58+
return elements.length === 1 ? elements[0] : null;
59+
}
60+
61+
getAll<T>(name: string): T[] {
62+
const elements: T[] = [];
63+
for (const [, module] of this.activeModules) {
64+
if (module.type === name) {
65+
elements.push(module.component as T);
66+
}
67+
}
68+
return elements;
69+
}
70+
}

frontend_omni/src/modules/module-system/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,17 @@ return (
3737
The module system is provided to child components via hooks, therefore a provider must be used like this:
3838

3939
```jsx
40-
const moduleManager = ...
40+
const activeModules = ...
4141
...
4242
return (
43-
<ModuleManagerContext value={moduleManager}>...</ModuleManagerContext>
43+
<ModulesContext value={activeModules}>...</ModulesContext>
4444
);
4545
```
4646

47-
The standard implementation `ModuleManagerProvider` makes this easier:
47+
The standard implementation `ModulesProvider` makes this easier:
4848

4949
```jsx
50-
<ModuleManagerProvider>...</ModuleManagerProvider>
50+
<ModulesProvider>...</ModulesProvider>
5151
```
5252

5353
## Sub Module Implementation Detail
Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createContext, useContext } from "react";
22

3-
// Module Manager Service Interface
4-
export interface ModuleManager {
3+
// Modules Service Interface
4+
export interface Modules {
55
/**
66
* Get a single component of a specific type across all modules.
77
* If more than one component with the same type exists, returns null.
@@ -14,20 +14,18 @@ export interface ModuleManager {
1414
getAll<T>(type: string): T[];
1515
}
1616

17-
// Create context for the module manager
18-
export const ModuleManagerContext = createContext<ModuleManager | null>(null);
17+
// Create context for the modules
18+
export const ModulesContext = createContext<Modules | null>(null);
1919

2020
/**
2121
* Hook to access modules and component discovery functionality
2222
*
23-
* @returns ModuleManager instance
23+
* @returns Modules instance
2424
*/
25-
export function useModules(): ModuleManager {
26-
const context = useContext(ModuleManagerContext);
25+
export function useModules(): Modules {
26+
const context = useContext(ModulesContext);
2727
if (!context) {
28-
throw new Error(
29-
"useModules must be used within a ModuleManagerProvider",
30-
);
28+
throw new Error("useModules must be used within a ModulesProvider");
3129
}
3230
return context;
3331
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { activateModules } from "./moduleActivator";
3+
import { ModuleRegistry } from "./moduleRegistry";
4+
import type { ModuleJsonEntry } from "./modulesJson";
5+
6+
// Mock the module registry to avoid importing actual modules with CSS dependencies
7+
vi.mock("../moduleRegistry", () => ({
8+
moduleRegistry: {
9+
"@/modules/base": {},
10+
"@/modules/level1": {},
11+
"@/modules/level2": {},
12+
"@/modules/a": {},
13+
"@/modules/b": {},
14+
"@/modules/independent": {},
15+
"@/modules/dependent": {},
16+
},
17+
}));
18+
19+
describe("Modules - Two-Phase Loading", () => {
20+
it("should register all modules and activate them recursively based on dependencies", () => {
21+
const modules: ModuleJsonEntry[] = [
22+
{
23+
id: "base",
24+
type: "Base",
25+
path: "@/modules/base",
26+
dependencies: [],
27+
},
28+
{
29+
id: "level1",
30+
type: "Level1",
31+
path: "@/modules/level1",
32+
dependencies: ["module:base"],
33+
},
34+
{
35+
id: "level2",
36+
type: "Level2",
37+
path: "@/modules/level2",
38+
dependencies: ["module:level1"],
39+
},
40+
];
41+
42+
const registry = new ModuleRegistry(modules);
43+
const activeModules = activateModules(registry);
44+
45+
// All modules should be registered
46+
expect(registry.getAll().length).toBe(3);
47+
48+
// All modules should be active since dependencies are satisfied
49+
expect(activeModules.some((m) => m.id === "base")).toBe(true);
50+
expect(activeModules.some((m) => m.id === "level1")).toBe(true);
51+
expect(activeModules.some((m) => m.id === "level2")).toBe(true);
52+
});
53+
54+
it("should register all modules but not activate circular dependencies", () => {
55+
const modules: ModuleJsonEntry[] = [
56+
{
57+
id: "module-a",
58+
type: "TypeA",
59+
path: "@/modules/a",
60+
dependencies: ["module:module-b"],
61+
},
62+
{
63+
id: "module-b",
64+
type: "TypeB",
65+
path: "@/modules/b",
66+
dependencies: ["module:module-a"],
67+
},
68+
];
69+
70+
const consoleWarnSpy = vi
71+
.spyOn(console, "warn")
72+
.mockImplementation(() => {});
73+
74+
const registry = new ModuleRegistry(modules);
75+
const activeModules = activateModules(registry);
76+
77+
// Both modules should be registered
78+
expect(registry.getAll().length).toBe(2);
79+
80+
// Neither should be active due to circular dependency
81+
expect(activeModules.some((m) => m.id === "module-a")).toBe(false);
82+
expect(activeModules.some((m) => m.id === "module-b")).toBe(false);
83+
84+
// Verify that unmet dependencies were logged
85+
expect(consoleWarnSpy).toHaveBeenCalledWith(
86+
"The following modules could not be activated due to unmet dependencies:",
87+
expect.any(Array),
88+
);
89+
90+
consoleWarnSpy.mockRestore();
91+
});
92+
93+
it("should register all modules and activate only independent ones", () => {
94+
const modules: ModuleJsonEntry[] = [
95+
{
96+
id: "independent",
97+
type: "Independent",
98+
path: "@/modules/independent",
99+
dependencies: [],
100+
},
101+
{
102+
id: "dependent",
103+
type: "Dependent",
104+
path: "@/modules/dependent",
105+
dependencies: ["module:missing"],
106+
},
107+
];
108+
109+
const registry = new ModuleRegistry(modules);
110+
const activeModules = activateModules(registry);
111+
112+
// Both modules should be registered
113+
expect(registry.getAll().length).toBe(2);
114+
115+
// Only independent should be active
116+
expect(activeModules.some((m) => m.id === "independent")).toBe(true);
117+
expect(activeModules.some((m) => m.id === "dependent")).toBe(false);
118+
});
119+
});

0 commit comments

Comments
 (0)