Skip to content

Commit 56b5bd6

Browse files
committed
feat: support module feature flags
1 parent 46ff57d commit 56b5bd6

File tree

5 files changed

+310
-45
lines changed

5 files changed

+310
-45
lines changed

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

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
import { useSuspenseQuery } from "@tanstack/react-query";
2-
import { type ReactNode, useMemo } from "react";
3-
import { ModulesContext } from ".";
2+
import { type ReactNode, useMemo, useState } from "react";
3+
import { ModuleFlagsContext, ModulesContext } from ".";
44
import { activateModules } from "./moduleActivator";
5-
import { LoadedModule, ModuleRegistry } from "./moduleRegistry";
5+
import { JsonModuleRegistry, type LoadedModule } from "./moduleRegistry";
66
import { fetchModulesJsonAsync } from "./modulesJson";
77

88
const modulesJsonPath = "/modules.json";
99

1010
interface ModulesProviderProps {
1111
children: ReactNode;
12+
initialFlags?: string[];
1213
}
1314

14-
export function ModulesProvider({ children }: ModulesProviderProps) {
15+
export function ModulesProvider({
16+
children,
17+
initialFlags = [],
18+
}: ModulesProviderProps) {
19+
const [flags, setFlagsState] = useState<string[]>(initialFlags);
20+
1521
const { data: modulesJson, error } = useSuspenseQuery({
1622
queryKey: ["modulesJson", modulesJsonPath],
1723
queryFn: () => fetchModulesJsonAsync(modulesJsonPath),
@@ -26,12 +32,35 @@ export function ModulesProvider({ children }: ModulesProviderProps) {
2632
}
2733

2834
const activeModules = useMemo(() => {
29-
const moduleRegistry = new ModuleRegistry(modulesJson.modules);
30-
const activeModules = activateModules(moduleRegistry);
35+
const moduleRegistry = new JsonModuleRegistry(modulesJson.modules);
36+
const activeModules = activateModules(moduleRegistry, flags);
3137
return new ActiveModules(activeModules);
32-
}, [modulesJson]);
38+
}, [modulesJson, flags]);
39+
40+
const set = (flag: string) => {
41+
setFlagsState((prevFlags) => {
42+
if (!prevFlags.includes(flag)) {
43+
return [...prevFlags, flag];
44+
}
45+
return prevFlags;
46+
});
47+
};
48+
49+
const remove = (flag: string) => {
50+
setFlagsState((prevFlags) => prevFlags.filter((f) => f !== flag));
51+
};
52+
53+
const moduleFlags = {
54+
set,
55+
remove,
56+
flags,
57+
};
3358

34-
return <ModulesContext value={activeModules}>{children}</ModulesContext>;
59+
return (
60+
<ModuleFlagsContext value={moduleFlags}>
61+
<ModulesContext value={activeModules}>{children}</ModulesContext>
62+
</ModuleFlagsContext>
63+
);
3564
}
3665

3766
class ActiveModules {

frontend_omni/src/modules/module-system/index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { createContext, useContext } from "react";
22

3-
// Modules Service Interface
43
export interface Modules {
54
/**
65
* Get a single component of a specific type across all modules.
@@ -14,8 +13,17 @@ export interface Modules {
1413
getAll<T>(type: string): T[];
1514
}
1615

16+
export interface ModuleFlags {
17+
set(flag: string): void;
18+
19+
remove(flag: string): void;
20+
21+
flags: string[];
22+
}
23+
1724
// Create context for the modules
1825
export const ModulesContext = createContext<Modules | null>(null);
26+
export const ModuleFlagsContext = createContext<ModuleFlags | null>(null);
1927

2028
/**
2129
* Hook to access modules and component discovery functionality
@@ -29,3 +37,11 @@ export function useModules(): Modules {
2937
}
3038
return context;
3139
}
40+
41+
export function useModuleFlags(): ModuleFlags {
42+
const context = useContext(ModuleFlagsContext);
43+
if (!context) {
44+
throw new Error("useModuleFlags must be used within a ModulesProvider");
45+
}
46+
return context;
47+
}

frontend_omni/src/modules/module-system/moduleActivator.test.ts

Lines changed: 175 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it, vi } from "vitest";
22
import { activateModules } from "./moduleActivator";
3-
import { ModuleRegistry } from "./moduleRegistry";
3+
import { JsonModuleRegistry } from "./moduleRegistry";
44
import type { ModuleJsonEntry } from "./modulesJson";
55

66
// Mock the module registry to avoid importing actual modules with CSS dependencies
@@ -13,6 +13,10 @@ vi.mock("../moduleRegistry", () => ({
1313
"@/modules/b": {},
1414
"@/modules/independent": {},
1515
"@/modules/dependent": {},
16+
"@/modules/flag-dependent": {},
17+
"@/modules/multi-flag": {},
18+
"@/modules/negated-flag": {},
19+
"@/modules/mixed-flags": {},
1620
},
1721
}));
1822

@@ -39,8 +43,8 @@ describe("Modules - Two-Phase Loading", () => {
3943
},
4044
];
4145

42-
const registry = new ModuleRegistry(modules);
43-
const activeModules = activateModules(registry);
46+
const registry = new JsonModuleRegistry(modules);
47+
const activeModules = activateModules(registry, []);
4448

4549
// All modules should be registered
4650
expect(registry.getAll().length).toBe(3);
@@ -71,8 +75,8 @@ describe("Modules - Two-Phase Loading", () => {
7175
.spyOn(console, "warn")
7276
.mockImplementation(() => {});
7377

74-
const registry = new ModuleRegistry(modules);
75-
const activeModules = activateModules(registry);
78+
const registry = new JsonModuleRegistry(modules);
79+
const activeModules = activateModules(registry, []);
7680

7781
// Both modules should be registered
7882
expect(registry.getAll().length).toBe(2);
@@ -83,7 +87,7 @@ describe("Modules - Two-Phase Loading", () => {
8387

8488
// Verify that unmet dependencies were logged
8589
expect(consoleWarnSpy).toHaveBeenCalledWith(
86-
"The following modules could not be activated due to unmet dependencies:",
90+
"The following modules could not be activated due to unmet module dependencies:",
8791
expect.any(Array),
8892
);
8993

@@ -106,8 +110,8 @@ describe("Modules - Two-Phase Loading", () => {
106110
},
107111
];
108112

109-
const registry = new ModuleRegistry(modules);
110-
const activeModules = activateModules(registry);
113+
const registry = new JsonModuleRegistry(modules);
114+
const activeModules = activateModules(registry, []);
111115

112116
// Both modules should be registered
113117
expect(registry.getAll().length).toBe(2);
@@ -116,4 +120,167 @@ describe("Modules - Two-Phase Loading", () => {
116120
expect(activeModules.some((m) => m.id === "independent")).toBe(true);
117121
expect(activeModules.some((m) => m.id === "dependent")).toBe(false);
118122
});
123+
124+
it("should activate modules with flag dependencies when flags are provided", () => {
125+
const modules: ModuleJsonEntry[] = [
126+
{
127+
id: "base",
128+
type: "Base",
129+
path: "@/modules/base",
130+
dependencies: [],
131+
},
132+
{
133+
id: "flag-dependent",
134+
type: "FlagDependent",
135+
path: "@/modules/flag-dependent",
136+
dependencies: ["flag:foo"],
137+
},
138+
];
139+
140+
const registry = new JsonModuleRegistry(modules);
141+
const activeFlags = ["foo"];
142+
const activeModules = activateModules(registry, activeFlags);
143+
144+
// Both modules should be registered
145+
expect(registry.getAll().length).toBe(2);
146+
147+
// Both should be active since flag is provided
148+
expect(activeModules.some((m) => m.id === "base")).toBe(true);
149+
expect(activeModules.some((m) => m.id === "flag-dependent")).toBe(true);
150+
});
151+
152+
it("should not activate modules with unmet flag dependencies", () => {
153+
const modules: ModuleJsonEntry[] = [
154+
{
155+
id: "base",
156+
type: "Base",
157+
path: "@/modules/base",
158+
dependencies: [],
159+
},
160+
{
161+
id: "flag-dependent",
162+
type: "FlagDependent",
163+
path: "@/modules/flag-dependent",
164+
dependencies: ["flag:foo"],
165+
},
166+
];
167+
168+
const registry = new JsonModuleRegistry(modules);
169+
const activeFlags: string[] = []; // No flags provided
170+
const activeModules = activateModules(registry, activeFlags);
171+
172+
// Both modules should be registered
173+
expect(registry.getAll().length).toBe(2);
174+
175+
// Only base should be active, flag-dependent should not
176+
expect(activeModules.some((m) => m.id === "base")).toBe(true);
177+
expect(activeModules.some((m) => m.id === "flag-dependent")).toBe(
178+
false,
179+
);
180+
});
181+
182+
it("should activate modules with multiple flag dependencies when all flags are provided", () => {
183+
const modules: ModuleJsonEntry[] = [
184+
{
185+
id: "multi-flag",
186+
type: "MultiFlag",
187+
path: "@/modules/multi-flag",
188+
dependencies: ["flag:foo", "flag:bar"],
189+
},
190+
];
191+
192+
const registry = new JsonModuleRegistry(modules);
193+
const activeFlags = ["foo", "bar"];
194+
const activeModules = activateModules(registry, activeFlags);
195+
196+
// Module should be registered
197+
expect(registry.getAll().length).toBe(1);
198+
199+
// Should be active since both flags are provided
200+
expect(activeModules.some((m) => m.id === "multi-flag")).toBe(true);
201+
});
202+
203+
it("should not activate modules with multiple flag dependencies when not all flags are provided", () => {
204+
const modules: ModuleJsonEntry[] = [
205+
{
206+
id: "multi-flag",
207+
type: "MultiFlag",
208+
path: "@/modules/multi-flag",
209+
dependencies: ["flag:foo", "flag:bar"],
210+
},
211+
];
212+
213+
const registry = new JsonModuleRegistry(modules);
214+
const activeFlags = ["foo"]; // Only one flag provided
215+
const activeModules = activateModules(registry, activeFlags);
216+
217+
// Module should be registered
218+
expect(registry.getAll().length).toBe(1);
219+
220+
// Should not be active since not all flags are provided
221+
expect(activeModules.some((m) => m.id === "multi-flag")).toBe(false);
222+
});
223+
224+
it("should activate modules with negated flag dependencies when flag is not provided", () => {
225+
const modules: ModuleJsonEntry[] = [
226+
{
227+
id: "negated-flag",
228+
type: "NegatedFlag",
229+
path: "@/modules/negated-flag",
230+
dependencies: ["flag:!foo"],
231+
},
232+
];
233+
234+
const registry = new JsonModuleRegistry(modules);
235+
const activeFlags: string[] = []; // foo is not provided
236+
const activeModules = activateModules(registry, activeFlags);
237+
238+
// Module should be registered
239+
expect(registry.getAll().length).toBe(1);
240+
241+
// Should be active since foo is not present
242+
expect(activeModules.some((m) => m.id === "negated-flag")).toBe(true);
243+
});
244+
245+
it("should not activate modules with negated flag dependencies when flag is provided", () => {
246+
const modules: ModuleJsonEntry[] = [
247+
{
248+
id: "negated-flag",
249+
type: "NegatedFlag",
250+
path: "@/modules/negated-flag",
251+
dependencies: ["flag:!foo"],
252+
},
253+
];
254+
255+
const registry = new JsonModuleRegistry(modules);
256+
const activeFlags = ["foo"]; // foo is provided
257+
const activeModules = activateModules(registry, activeFlags);
258+
259+
// Module should be registered
260+
expect(registry.getAll().length).toBe(1);
261+
262+
// Should not be active since foo is present
263+
expect(activeModules.some((m) => m.id === "negated-flag")).toBe(false);
264+
});
265+
266+
it("should activate modules with mixed positive and negated flag dependencies", () => {
267+
const modules: ModuleJsonEntry[] = [
268+
{
269+
id: "mixed-flags",
270+
type: "MixedFlags",
271+
path: "@/modules/mixed-flags",
272+
dependencies: ["flag:foo", "flag:!bar"],
273+
},
274+
];
275+
276+
const registry = new JsonModuleRegistry(modules);
277+
const activeFlags = ["foo"]; // foo is present, bar is not
278+
const activeModules = activateModules(registry, activeFlags);
279+
280+
// Module should be registered
281+
expect(registry.getAll().length).toBe(1);
282+
283+
// Should be active since foo is present and bar is not present
284+
expect(activeModules.some((m) => m.id === "mixed-flags")).toBe(true);
285+
});
119286
});

0 commit comments

Comments
 (0)