@checkmate-monitor/frontend-api 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # @checkmate-monitor/frontend-api
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [ffc28f6]
8
+ - @checkmate-monitor/common@0.1.0
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@checkmate-monitor/frontend-api",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "./src/index.ts",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit",
8
+ "lint": "bun run lint:code",
9
+ "lint:code": "eslint . --max-warnings 0"
10
+ },
11
+ "peerDependencies": {
12
+ "react": "^18.0.0"
13
+ },
14
+ "dependencies": {
15
+ "@checkmate-monitor/common": "workspace:*",
16
+ "@orpc/client": "^1.13.2"
17
+ },
18
+ "devDependencies": {
19
+ "@types/bun": "^1.3.5",
20
+ "@types/react": "^18.0.0",
21
+ "typescript": "^5.0.0",
22
+ "@checkmate-monitor/tsconfig": "workspace:*",
23
+ "@checkmate-monitor/scripts": "workspace:*"
24
+ }
25
+ }
@@ -0,0 +1,53 @@
1
+ import React, { createContext, useContext, ReactNode } from "react";
2
+ import { ApiRef } from "./api-ref";
3
+
4
+ export type ApiRegistry = Map<string, unknown>;
5
+
6
+ const ApiContext = createContext<ApiRegistry | undefined>(undefined);
7
+
8
+ export class ApiRegistryBuilder {
9
+ private registry: ApiRegistry = new Map();
10
+
11
+ register<T>(ref: ApiRef<T>, impl: T) {
12
+ this.registry.set(ref.id, impl);
13
+ return this;
14
+ }
15
+
16
+ registerFactory<T>(ref: ApiRef<T>, factory: (registry: ApiRegistry) => T) {
17
+ const impl = factory(this.registry);
18
+ this.registry.set(ref.id, impl);
19
+ return this;
20
+ }
21
+
22
+ build() {
23
+ return this.registry;
24
+ }
25
+ }
26
+
27
+ export const ApiProvider = ({
28
+ registry,
29
+ children,
30
+ }: {
31
+ registry: ApiRegistry;
32
+ children: ReactNode;
33
+ }) => {
34
+ return React.createElement(
35
+ ApiContext.Provider,
36
+ { value: registry },
37
+ children
38
+ );
39
+ };
40
+
41
+ export function useApi<T>(ref: ApiRef<T>): T {
42
+ const registry = useContext(ApiContext);
43
+ if (!registry) {
44
+ throw new Error("useApi must be used within an ApiProvider");
45
+ }
46
+
47
+ const output = registry.get(ref.id);
48
+ if (!output) {
49
+ throw new Error(`No implementation found for API '${ref.id}'`);
50
+ }
51
+
52
+ return output as T;
53
+ }
package/src/api-ref.ts ADDED
@@ -0,0 +1,15 @@
1
+ export type ApiRef<T> = {
2
+ id: string;
3
+ T: T;
4
+ toString(): string;
5
+ };
6
+
7
+ export function createApiRef<T>(id: string): ApiRef<T> {
8
+ return {
9
+ id,
10
+ T: undefined as T,
11
+ toString() {
12
+ return `ApiRef(${id})`;
13
+ },
14
+ };
15
+ }
@@ -0,0 +1,46 @@
1
+ import { pluginRegistry } from "../plugin-registry";
2
+ import type { SlotContext } from "../plugin";
3
+ import type { SlotDefinition } from "../slots";
4
+
5
+ /**
6
+ * Type-safe props for ExtensionSlot.
7
+ * Extracts the context type from the slot definition itself,
8
+ * ensuring the context matches what the slot expects.
9
+ */
10
+ type ExtensionSlotProps<TSlot extends SlotDefinition<unknown>> =
11
+ SlotContext<TSlot> extends undefined
12
+ ? { slot: TSlot; context?: undefined }
13
+ : { slot: TSlot; context: SlotContext<TSlot> };
14
+
15
+ /**
16
+ * Renders all extensions registered for the given slot.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * // Slot with context - context is required and type-checked
21
+ * <ExtensionSlot slot={SystemDetailsSlot} context={{ system }} />
22
+ *
23
+ * // Slot without context
24
+ * <ExtensionSlot slot={NavbarSlot} />
25
+ * ```
26
+ */
27
+ export function ExtensionSlot<TSlot extends SlotDefinition<unknown>>({
28
+ slot,
29
+ context,
30
+ }: ExtensionSlotProps<TSlot>) {
31
+ const extensions = pluginRegistry.getExtensions(slot.id);
32
+
33
+ if (extensions.length === 0) {
34
+ return <></>;
35
+ }
36
+
37
+ return (
38
+ <>
39
+ {extensions.map((ext) => {
40
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
+ const Component = ext.component as React.ComponentType<any>;
42
+ return <Component key={ext.id} {...(context ?? {})} />;
43
+ })}
44
+ </>
45
+ );
46
+ }
@@ -0,0 +1,41 @@
1
+ import {
2
+ PermissionAction,
3
+ ClientDefinition,
4
+ InferClient,
5
+ } from "@checkmate-monitor/common";
6
+ import { createApiRef } from "./api-ref";
7
+
8
+ export interface LoggerApi {
9
+ info(message: string, ...args: unknown[]): void;
10
+ error(message: string, ...args: unknown[]): void;
11
+ warn(message: string, ...args: unknown[]): void;
12
+ debug(message: string, ...args: unknown[]): void;
13
+ }
14
+
15
+ export interface FetchApi {
16
+ fetch(input: string | URL, init?: RequestInit): Promise<Response>;
17
+ forPlugin(pluginId: string): {
18
+ fetch(path: string, init?: RequestInit): Promise<Response>;
19
+ };
20
+ }
21
+
22
+ export const loggerApiRef = createApiRef<LoggerApi>("core.logger");
23
+ export const fetchApiRef = createApiRef<FetchApi>("core.fetch");
24
+
25
+ export interface PermissionApi {
26
+ usePermission(permission: string): { loading: boolean; allowed: boolean };
27
+ useResourcePermission(
28
+ resource: string,
29
+ action: PermissionAction
30
+ ): { loading: boolean; allowed: boolean };
31
+ useManagePermission(resource: string): { loading: boolean; allowed: boolean };
32
+ }
33
+
34
+ export const permissionApiRef = createApiRef<PermissionApi>("core.permission");
35
+
36
+ export interface RpcApi {
37
+ client: unknown;
38
+ forPlugin<T extends ClientDefinition>(def: T): InferClient<T>;
39
+ }
40
+
41
+ export const rpcApiRef = createApiRef<RpcApi>("core.rpc");
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from "./api-ref";
2
+ export * from "./api-context";
3
+ export * from "./core-apis";
4
+ export * from "./plugin";
5
+ export * from "./plugin-registry";
6
+ export * from "./components/ExtensionSlot";
7
+ export * from "./utils";
8
+ export * from "./slots";
9
+ export * from "./use-plugin-route";
@@ -0,0 +1,245 @@
1
+ import { FrontendPlugin, Extension } from "./plugin";
2
+
3
+ /**
4
+ * Listener function for registry changes
5
+ */
6
+ type RegistryListener = () => void;
7
+
8
+ /**
9
+ * Resolved route information for runtime access.
10
+ */
11
+ interface ResolvedRoute {
12
+ id: string;
13
+ path: string;
14
+ pluginId: string;
15
+ element?: React.ReactNode;
16
+ title?: string;
17
+ permission?: string;
18
+ }
19
+
20
+ class PluginRegistry {
21
+ private plugins: FrontendPlugin[] = [];
22
+ private extensions = new Map<string, Extension[]>();
23
+ private routeMap = new Map<string, ResolvedRoute>();
24
+
25
+ /**
26
+ * Version counter that increments on every registry change.
27
+ * Used by React components to trigger re-renders when plugins are added/removed.
28
+ */
29
+ private version = 0;
30
+ private listeners: Set<RegistryListener> = new Set();
31
+
32
+ /**
33
+ * Validate and register routes from a plugin.
34
+ */
35
+ private registerRoutes(plugin: FrontendPlugin) {
36
+ if (!plugin.routes) return;
37
+
38
+ const pluginId = plugin.metadata.pluginId;
39
+
40
+ for (const route of plugin.routes) {
41
+ // Validate that route's pluginId matches the frontend plugin
42
+ if (route.route.pluginId !== pluginId) {
43
+ console.error(
44
+ `❌ Route pluginId mismatch: route "${route.route.id}" has pluginId "${route.route.pluginId}" ` +
45
+ `but plugin is "${pluginId}"`
46
+ );
47
+ throw new Error(
48
+ `Route pluginId "${route.route.pluginId}" doesn't match plugin "${pluginId}"`
49
+ );
50
+ }
51
+
52
+ const fullPath = `/${route.route.pluginId}${
53
+ route.route.path.startsWith("/")
54
+ ? route.route.path
55
+ : `/${route.route.path}`
56
+ }`;
57
+
58
+ const resolvedRoute: ResolvedRoute = {
59
+ id: route.route.id,
60
+ path: fullPath,
61
+ pluginId: route.route.pluginId,
62
+ element: route.element,
63
+ title: route.title,
64
+ permission: route.permission?.id,
65
+ };
66
+
67
+ // Add to route map for resolution
68
+ this.routeMap.set(route.route.id, resolvedRoute);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Unregister routes from a plugin.
74
+ */
75
+ private unregisterRoutes(plugin: FrontendPlugin) {
76
+ if (!plugin.routes) return;
77
+
78
+ for (const route of plugin.routes) {
79
+ this.routeMap.delete(route.route.id);
80
+ }
81
+ }
82
+
83
+ register(plugin: FrontendPlugin) {
84
+ const pluginId = plugin.metadata.pluginId;
85
+
86
+ // Avoid duplicate registration
87
+ if (this.plugins.some((p) => p.metadata.pluginId === pluginId)) {
88
+ console.warn(`⚠️ Plugin ${pluginId} already registered`);
89
+ return;
90
+ }
91
+
92
+ console.log(`🔌 Registering frontend plugin: ${pluginId}`);
93
+ this.plugins.push(plugin);
94
+
95
+ if (plugin.extensions) {
96
+ for (const extension of plugin.extensions) {
97
+ if (!this.extensions.has(extension.slot.id)) {
98
+ this.extensions.set(extension.slot.id, []);
99
+ }
100
+ this.extensions.get(extension.slot.id)!.push(extension);
101
+ }
102
+ }
103
+
104
+ this.registerRoutes(plugin);
105
+ this.incrementVersion();
106
+ }
107
+
108
+ /**
109
+ * Unregister a plugin by ID.
110
+ * Removes the plugin and all its extensions from the registry.
111
+ */
112
+ unregister(pluginId: string): boolean {
113
+ const pluginIndex = this.plugins.findIndex(
114
+ (p) => p.metadata.pluginId === pluginId
115
+ );
116
+ if (pluginIndex === -1) {
117
+ console.warn(`⚠️ Plugin ${pluginId} not found for unregistration`);
118
+ return false;
119
+ }
120
+
121
+ const plugin = this.plugins[pluginIndex];
122
+ console.log(`🔌 Unregistering frontend plugin: ${pluginId}`);
123
+
124
+ // Remove plugin from list
125
+ this.plugins.splice(pluginIndex, 1);
126
+
127
+ // Remove extensions
128
+ if (plugin.extensions) {
129
+ for (const extension of plugin.extensions) {
130
+ const slotExtensions = this.extensions.get(extension.slot.id);
131
+ if (slotExtensions) {
132
+ const extIndex = slotExtensions.findIndex(
133
+ (e) => e.id === extension.id
134
+ );
135
+ if (extIndex !== -1) {
136
+ slotExtensions.splice(extIndex, 1);
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ this.unregisterRoutes(plugin);
143
+ this.incrementVersion();
144
+ return true;
145
+ }
146
+
147
+ /**
148
+ * Check if a plugin is registered.
149
+ */
150
+ hasPlugin(pluginId: string): boolean {
151
+ return this.plugins.some((p) => p.metadata.pluginId === pluginId);
152
+ }
153
+
154
+ getPlugins() {
155
+ return this.plugins;
156
+ }
157
+
158
+ getExtensions(slotId: string): Extension[] {
159
+ return this.extensions.get(slotId) || [];
160
+ }
161
+
162
+ /**
163
+ * Get all routes for rendering in the router.
164
+ */
165
+ getAllRoutes() {
166
+ return this.plugins.flatMap((plugin) => {
167
+ return (plugin.routes || []).map((route) => {
168
+ const fullPath = `/${route.route.pluginId}${
169
+ route.route.path.startsWith("/")
170
+ ? route.route.path
171
+ : `/${route.route.path}`
172
+ }`;
173
+
174
+ return {
175
+ path: fullPath,
176
+ element: route.element,
177
+ title: route.title,
178
+ permission: route.permission?.id,
179
+ };
180
+ });
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Resolve a route by its ID to get the full path.
186
+ *
187
+ * @param routeId - Route ID in format "{pluginId}.{routeName}"
188
+ * @param params - Optional path parameters to substitute
189
+ * @returns The resolved full path, or undefined if not found
190
+ */
191
+ resolveRoute(
192
+ routeId: string,
193
+ params?: Record<string, string>
194
+ ): string | undefined {
195
+ const route = this.routeMap.get(routeId);
196
+ if (!route) {
197
+ console.warn(`⚠️ Route "${routeId}" not found in registry`);
198
+ return undefined;
199
+ }
200
+
201
+ if (!params) {
202
+ return route.path;
203
+ }
204
+
205
+ // Substitute path parameters
206
+ let result = route.path;
207
+ for (const [key, value] of Object.entries(params)) {
208
+ result = result.replace(`:${key}`, value);
209
+ }
210
+ return result;
211
+ }
212
+
213
+ /**
214
+ * Get the current version number.
215
+ * Increments on every register/unregister.
216
+ */
217
+ getVersion(): number {
218
+ return this.version;
219
+ }
220
+
221
+ /**
222
+ * Subscribe to registry changes.
223
+ * Returns an unsubscribe function.
224
+ */
225
+ subscribe(listener: RegistryListener): () => void {
226
+ this.listeners.add(listener);
227
+ return () => this.listeners.delete(listener);
228
+ }
229
+
230
+ private incrementVersion() {
231
+ this.version++;
232
+ for (const listener of this.listeners) {
233
+ listener();
234
+ }
235
+ }
236
+
237
+ reset() {
238
+ this.plugins = [];
239
+ this.extensions.clear();
240
+ this.routeMap.clear();
241
+ this.incrementVersion();
242
+ }
243
+ }
244
+
245
+ export const pluginRegistry = new PluginRegistry();
package/src/plugin.ts ADDED
@@ -0,0 +1,75 @@
1
+ import React from "react";
2
+ import { ApiRef } from "./api-ref";
3
+ import type { SlotDefinition } from "./slots";
4
+ import type {
5
+ RouteDefinition,
6
+ PluginMetadata,
7
+ Permission,
8
+ } from "@checkmate-monitor/common";
9
+
10
+ /**
11
+ * Extract the context type from a SlotDefinition
12
+ */
13
+ export type SlotContext<T> = T extends SlotDefinition<infer C> ? C : never;
14
+
15
+ /**
16
+ * Type-safe extension that infers component props from the slot definition.
17
+ */
18
+ export interface Extension<
19
+ TSlot extends SlotDefinition<unknown> = SlotDefinition<unknown>
20
+ > {
21
+ id: string;
22
+ slot: TSlot;
23
+ component: React.ComponentType<SlotContext<TSlot>>;
24
+ }
25
+
26
+ /**
27
+ * Helper to create a type-safe extension from a slot definition.
28
+ * This ensures the component props match the slot's expected context.
29
+ */
30
+ export function createSlotExtension<TSlot extends SlotDefinition<unknown>>(
31
+ slot: TSlot,
32
+ extension: Omit<Extension<TSlot>, "slot">
33
+ ): Extension<TSlot> {
34
+ return {
35
+ ...extension,
36
+ slot,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Route configuration for a frontend plugin.
42
+ * Uses RouteDefinition from the plugin's common package.
43
+ */
44
+ export interface PluginRoute {
45
+ /** Route definition from common package */
46
+ route: RouteDefinition;
47
+
48
+ /** React element to render */
49
+ element?: React.ReactNode;
50
+
51
+ /** Page title */
52
+ title?: string;
53
+
54
+ /** Permission required to access this route (use permission object from common package) */
55
+ permission?: Permission;
56
+ }
57
+
58
+ /**
59
+ * Frontend plugin configuration.
60
+ * Uses PluginMetadata from the common package for consistent plugin identification.
61
+ */
62
+ export interface FrontendPlugin {
63
+ /** Plugin metadata from the common package (contains pluginId) */
64
+ metadata: PluginMetadata;
65
+ extensions?: Extension[];
66
+ apis?: {
67
+ ref: ApiRef<unknown>;
68
+ factory: (deps: { get: <T>(ref: ApiRef<T>) => T }) => unknown;
69
+ }[];
70
+ routes?: PluginRoute[];
71
+ }
72
+
73
+ export function createFrontendPlugin(plugin: FrontendPlugin): FrontendPlugin {
74
+ return plugin;
75
+ }
@@ -0,0 +1,200 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2
+ import { createSlot, SlotDefinition } from "./slots";
3
+ import { pluginRegistry } from "./plugin-registry";
4
+ import { createFrontendPlugin } from "./plugin";
5
+
6
+ describe("createSlot", () => {
7
+ test("creates a slot definition with the correct id", () => {
8
+ const slot = createSlot("test.slot.id");
9
+ expect(slot.id).toBe("test.slot.id");
10
+ });
11
+
12
+ test("creates a slot definition with typed context", () => {
13
+ interface MyContext {
14
+ userId: string;
15
+ label: string;
16
+ }
17
+
18
+ const slot = createSlot<MyContext>("typed.slot");
19
+ expect(slot.id).toBe("typed.slot");
20
+
21
+ // Type check: slot._contextType should be assignable to MyContext
22
+ // This is a compile-time check, at runtime _contextType is undefined
23
+ const contextType: MyContext | undefined = slot._contextType;
24
+ expect(contextType).toBeUndefined();
25
+ });
26
+
27
+ test("creates slot with undefined context type by default", () => {
28
+ const slot = createSlot("simple.slot");
29
+ expect(slot.id).toBe("simple.slot");
30
+ expect(slot._contextType).toBeUndefined();
31
+ });
32
+ });
33
+
34
+ describe("Cross-Plugin Slot Usage", () => {
35
+ // Simulate: PluginA exposes a slot from its common package
36
+ const PluginASlot = createSlot<{ message: string }>("plugin-a.custom.slot");
37
+
38
+ // Simulate: PluginB creates an extension for PluginA's slot
39
+ const MockComponent = () => null;
40
+
41
+ beforeEach(() => {
42
+ pluginRegistry.reset();
43
+ });
44
+
45
+ afterEach(() => {
46
+ pluginRegistry.reset();
47
+ });
48
+
49
+ test("PluginB can register an extension to PluginA's exported slot", () => {
50
+ // PluginA would typically define its slot in its -common package
51
+ // e.g., export const PluginASlot = createSlot<...>("plugin-a.custom.slot")
52
+
53
+ // PluginB imports and uses the slot:
54
+ const pluginB = createFrontendPlugin({
55
+ metadata: { pluginId: "plugin-b" },
56
+ extensions: [
57
+ {
58
+ id: "plugin-b.extension-for-plugin-a",
59
+ slot: PluginASlot, // Using the SlotDefinition directly
60
+ component: MockComponent,
61
+ },
62
+ ],
63
+ });
64
+
65
+ pluginRegistry.register(pluginB);
66
+
67
+ // Verify the extension is registered to the correct slot
68
+ const extensions = pluginRegistry.getExtensions(PluginASlot.id);
69
+ expect(extensions).toHaveLength(1);
70
+ expect(extensions[0].id).toBe("plugin-b.extension-for-plugin-a");
71
+ expect(extensions[0].component).toBe(MockComponent);
72
+ });
73
+
74
+ test("multiple plugins can register extensions to the same slot", () => {
75
+ const MockComponentB = () => null;
76
+ const MockComponentC = () => null;
77
+
78
+ const pluginB = createFrontendPlugin({
79
+ metadata: { pluginId: "plugin-b" },
80
+ extensions: [
81
+ {
82
+ id: "plugin-b.extension",
83
+ slot: PluginASlot,
84
+ component: MockComponentB,
85
+ },
86
+ ],
87
+ });
88
+
89
+ const pluginC = createFrontendPlugin({
90
+ metadata: { pluginId: "plugin-c" },
91
+ extensions: [
92
+ {
93
+ id: "plugin-c.extension",
94
+ slot: PluginASlot,
95
+ component: MockComponentC,
96
+ },
97
+ ],
98
+ });
99
+
100
+ pluginRegistry.register(pluginB);
101
+ pluginRegistry.register(pluginC);
102
+
103
+ const extensions = pluginRegistry.getExtensions(PluginASlot.id);
104
+ expect(extensions).toHaveLength(2);
105
+ expect(extensions.map((e) => e.id)).toContain("plugin-b.extension");
106
+ expect(extensions.map((e) => e.id)).toContain("plugin-c.extension");
107
+ });
108
+
109
+ test("extensions are removed when plugin is unregistered", () => {
110
+ const pluginB = createFrontendPlugin({
111
+ metadata: { pluginId: "plugin-b" },
112
+ extensions: [
113
+ {
114
+ id: "plugin-b.extension",
115
+ slot: PluginASlot,
116
+ component: MockComponent,
117
+ },
118
+ ],
119
+ });
120
+
121
+ pluginRegistry.register(pluginB);
122
+ expect(pluginRegistry.getExtensions(PluginASlot.id)).toHaveLength(1);
123
+
124
+ pluginRegistry.unregister("plugin-b");
125
+ expect(pluginRegistry.getExtensions(PluginASlot.id)).toHaveLength(0);
126
+ });
127
+
128
+ test("slot id property is readonly and consistent", () => {
129
+ const slot1 = createSlot("consistent.slot");
130
+ const slot2 = createSlot("consistent.slot");
131
+
132
+ // Same id string creates equivalent slot references
133
+ expect(slot1.id).toBe(slot2.id);
134
+
135
+ // Extensions registered to either slot.id will be in the same collection
136
+ const pluginB = createFrontendPlugin({
137
+ metadata: { pluginId: "plugin-b" },
138
+ extensions: [
139
+ {
140
+ id: "plugin-b.ext1",
141
+ slot: slot1,
142
+ component: MockComponent,
143
+ },
144
+ ],
145
+ });
146
+
147
+ const pluginC = createFrontendPlugin({
148
+ metadata: { pluginId: "plugin-c" },
149
+ extensions: [
150
+ {
151
+ id: "plugin-c.ext2",
152
+ slot: slot2,
153
+ component: MockComponent,
154
+ },
155
+ ],
156
+ });
157
+
158
+ pluginRegistry.register(pluginB);
159
+ pluginRegistry.register(pluginC);
160
+
161
+ // Both extensions should be in the same slot
162
+ expect(pluginRegistry.getExtensions(slot1.id)).toHaveLength(2);
163
+ expect(pluginRegistry.getExtensions(slot2.id)).toHaveLength(2);
164
+ });
165
+ });
166
+
167
+ describe("SlotDefinition type safety", () => {
168
+ test("slot context type is preserved as phantom type", () => {
169
+ interface SystemContext {
170
+ systemId: string;
171
+ systemName: string;
172
+ }
173
+
174
+ const slot: SlotDefinition<SystemContext> =
175
+ createSlot<SystemContext>("system.details");
176
+
177
+ // The id is accessible
178
+ expect(slot.id).toBe("system.details");
179
+
180
+ // Type assertion to verify the phantom type is correct
181
+ // This is a compile-time check - the actual value is undefined
182
+ type ExtractedContext = NonNullable<typeof slot._contextType>;
183
+ const _typeCheck: ExtractedContext = { systemId: "1", systemName: "Test" };
184
+ expect(_typeCheck).toBeDefined();
185
+ });
186
+
187
+ test("createSlot with object context type", () => {
188
+ const slot = createSlot<{ count: number; items: string[] }>("complex.slot");
189
+ expect(slot.id).toBe("complex.slot");
190
+ });
191
+
192
+ test("createSlot without context type defaults to undefined", () => {
193
+ const slot = createSlot("no-context.slot");
194
+
195
+ // Type should be SlotDefinition<undefined>
196
+ // When NonNullable is applied to undefined, it becomes never
197
+ // This is just a compile-time type verification
198
+ expect(slot.id).toBe("no-context.slot");
199
+ });
200
+ });
package/src/slots.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A type-safe slot definition that can be exported from plugin common packages.
3
+ * The context type parameter defines what props extensions will receive.
4
+ */
5
+ export interface SlotDefinition<TContext = undefined> {
6
+ /** Unique slot identifier, recommended format: "plugin-name.area.purpose" */
7
+ readonly id: string;
8
+ /** Phantom type for context type inference - do not use directly */
9
+ readonly _contextType?: TContext;
10
+ }
11
+
12
+ /**
13
+ * Creates a type-safe slot definition that can be exported from any package.
14
+ *
15
+ * @example
16
+ * // In @checkmate-monitor/catalog-common
17
+ * export const SystemDetailsSlot = createSlot<{ systemId: string }>(
18
+ * "catalog.system.details"
19
+ * );
20
+ *
21
+ * // In your frontend plugin
22
+ * extensions: [{
23
+ * id: "my-plugin.system-details",
24
+ * slot: SystemDetailsSlot,
25
+ * component: MySystemDetailsExtension, // Receives { systemId: string }
26
+ * }]
27
+ *
28
+ * @param id - Unique slot identifier
29
+ * @returns A slot definition that can be used for type-safe extension registration
30
+ */
31
+ export function createSlot<TContext = undefined>(
32
+ id: string
33
+ ): SlotDefinition<TContext> {
34
+ return { id } as SlotDefinition<TContext>;
35
+ }
36
+
37
+ /**
38
+ * Core layout slots - no context required
39
+ */
40
+ export const DashboardSlot = createSlot("dashboard");
41
+ export const NavbarSlot = createSlot("core.layout.navbar");
42
+ export const NavbarMainSlot = createSlot("core.layout.navbar.main");
43
+ export const UserMenuItemsSlot = createSlot(
44
+ "core.layout.navbar.user-menu.items"
45
+ );
46
+ export const UserMenuItemsBottomSlot = createSlot(
47
+ "core.layout.navbar.user-menu.items.bottom"
48
+ );
@@ -0,0 +1,65 @@
1
+ import { useCallback } from "react";
2
+ import type { RouteDefinition } from "@checkmate-monitor/common";
3
+ import { resolveRoute } from "@checkmate-monitor/common";
4
+ import { pluginRegistry } from "./plugin-registry";
5
+
6
+ /**
7
+ * React hook for resolving plugin routes to full paths.
8
+ *
9
+ * Can resolve routes in two ways:
10
+ * 1. Using a RouteDefinition object from a plugin's routes (type-safe, recommended)
11
+ * 2. Using a route ID string (for cross-plugin resolution)
12
+ *
13
+ * @example Using RouteDefinition (recommended)
14
+ * ```tsx
15
+ * import { maintenanceRoutes } from "@checkmate-monitor/maintenance-common";
16
+ *
17
+ * const MyComponent = () => {
18
+ * const getRoute = usePluginRoute();
19
+ *
20
+ * return (
21
+ * <Link to={getRoute(maintenanceRoutes.routes.config)}>Config</Link>
22
+ * );
23
+ * };
24
+ * ```
25
+ *
26
+ * @example With path parameters
27
+ * ```tsx
28
+ * const getRoute = usePluginRoute();
29
+ *
30
+ * // For a route like "/detail/:id"
31
+ * const detailPath = getRoute(maintenanceRoutes.routes.detail, { id: "123" });
32
+ * // Returns: "/maintenance/detail/123"
33
+ * ```
34
+ *
35
+ * @example Using route ID string (cross-plugin)
36
+ * ```tsx
37
+ * const path = getRoute("maintenance.config");
38
+ * ```
39
+ */
40
+ export function usePluginRoute(): {
41
+ <TParams extends string>(
42
+ route: RouteDefinition<TParams>,
43
+ params?: TParams extends never ? never : Record<TParams, string>
44
+ ): string;
45
+ (routeId: string, params?: Record<string, string>): string | undefined;
46
+ } {
47
+ return useCallback(
48
+ (
49
+ routeOrId: RouteDefinition | string,
50
+ params?: Record<string, string>
51
+ ): string | undefined => {
52
+ if (typeof routeOrId === "string") {
53
+ // Resolve by route ID through registry
54
+ return pluginRegistry.resolveRoute(routeOrId, params);
55
+ }
56
+ // Resolve using route definition directly
57
+ // Cast needed because overload signatures hide implementation details
58
+ if (params) {
59
+ return resolveRoute(routeOrId as RouteDefinition<string>, params);
60
+ }
61
+ return resolveRoute(routeOrId as RouteDefinition<never>);
62
+ },
63
+ []
64
+ ) as ReturnType<typeof usePluginRoute>;
65
+ }
package/src/utils.tsx ADDED
@@ -0,0 +1,16 @@
1
+ import React, { Suspense, ComponentType } from "react";
2
+
3
+ export function wrapInSuspense<P extends object>(
4
+ Component: ComponentType<P>,
5
+ fallback: React.ReactNode = <div>Loading...</div>
6
+ ): React.FC<P> {
7
+ const WrappedComponent: React.FC<P> = (props) => (
8
+ <Suspense fallback={fallback}>
9
+ <Component {...props} />
10
+ </Suspense>
11
+ );
12
+ WrappedComponent.displayName = `Suspense(${
13
+ Component.displayName || Component.name || "Component"
14
+ })`;
15
+ return WrappedComponent;
16
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkmate-monitor/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }