Skip to content

Commit 7cbbab7

Browse files
Copilothotlong
andcommitted
feat: Add namespace support to ComponentRegistry
- Add namespace field to ComponentMeta type - Update Registry.register() to support namespace option and construct full type as namespace:type - Update Registry.get(), getConfig(), and has() to support namespace lookup with fallback to non-namespaced - Add deprecation warning for non-namespaced registrations - Add comprehensive tests for namespace functionality (23 tests, all passing) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent d28de2f commit 7cbbab7

2 files changed

Lines changed: 317 additions & 7 deletions

File tree

packages/core/src/registry/Registry.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type ComponentMeta = {
2626
label?: string; // Display name in designer
2727
icon?: string; // Icon name or svg string
2828
category?: string; // Grouping category
29+
namespace?: string; // Component namespace (e.g., 'ui', 'field', 'plugin-grid')
2930
inputs?: ComponentInput[];
3031
defaultProps?: Record<string, any>; // Default props when dropped
3132
defaultChildren?: SchemaNode[]; // Default children when dropped
@@ -51,25 +52,70 @@ export class Registry<T = any> {
5152
private components = new Map<string, ComponentConfig<T>>();
5253

5354
register(type: string, component: ComponentRenderer<T>, meta?: ComponentMeta) {
54-
if (this.components.has(type)) {
55-
// console.warn(`Component type "${type}" is already registered. Overwriting.`);
55+
// Construct the full type with namespace if provided
56+
const namespace = meta?.namespace;
57+
const fullType = namespace ? `${namespace}:${type}` : type;
58+
59+
// Warn if overwriting an existing registration
60+
if (this.components.has(fullType)) {
61+
// console.warn(`Component type "${fullType}" is already registered. Overwriting.`);
5662
}
57-
this.components.set(type, {
58-
type,
63+
64+
// Deprecation warning for non-namespaced registrations
65+
// (only for new registrations, not for backward compatibility lookups)
66+
if (!namespace && typeof console !== 'undefined' && console.warn) {
67+
console.warn(
68+
`[ObjectUI] Registering component "${type}" without a namespace is deprecated. ` +
69+
`Please provide a namespace via the meta.namespace option. ` +
70+
`Example: ComponentRegistry.register('${type}', component, { namespace: 'ui' })`
71+
);
72+
}
73+
74+
this.components.set(fullType, {
75+
type: fullType,
5976
component,
6077
...meta
6178
});
6279
}
6380

64-
get(type: string): ComponentRenderer<T> | undefined {
81+
get(type: string, namespace?: string): ComponentRenderer<T> | undefined {
82+
// First try with namespace if provided
83+
if (namespace) {
84+
const namespacedType = `${namespace}:${type}`;
85+
const component = this.components.get(namespacedType)?.component;
86+
if (component) {
87+
return component;
88+
}
89+
}
90+
91+
// Fallback to non-namespaced lookup for backward compatibility
6592
return this.components.get(type)?.component;
6693
}
6794

68-
getConfig(type: string): ComponentConfig<T> | undefined {
95+
getConfig(type: string, namespace?: string): ComponentConfig<T> | undefined {
96+
// First try with namespace if provided
97+
if (namespace) {
98+
const namespacedType = `${namespace}:${type}`;
99+
const config = this.components.get(namespacedType);
100+
if (config) {
101+
return config;
102+
}
103+
}
104+
105+
// Fallback to non-namespaced lookup for backward compatibility
69106
return this.components.get(type);
70107
}
71108

72-
has(type: string): boolean {
109+
has(type: string, namespace?: string): boolean {
110+
// First try with namespace if provided
111+
if (namespace) {
112+
const namespacedType = `${namespace}:${type}`;
113+
if (this.components.has(namespacedType)) {
114+
return true;
115+
}
116+
}
117+
118+
// Fallback to non-namespaced lookup for backward compatibility
73119
return this.components.has(type);
74120
}
75121

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10+
import { Registry } from '../Registry';
11+
12+
describe('Registry', () => {
13+
let registry: Registry;
14+
let consoleWarnSpy: any;
15+
16+
beforeEach(() => {
17+
registry = new Registry();
18+
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
19+
});
20+
21+
afterEach(() => {
22+
consoleWarnSpy.mockRestore();
23+
});
24+
25+
describe('Basic Registration', () => {
26+
it('should register a component without namespace', () => {
27+
const component = () => 'test';
28+
registry.register('button', component);
29+
30+
expect(registry.has('button')).toBe(true);
31+
expect(registry.get('button')).toBe(component);
32+
});
33+
34+
it('should warn when registering without namespace', () => {
35+
const component = () => 'test';
36+
registry.register('button', component);
37+
38+
expect(consoleWarnSpy).toHaveBeenCalledWith(
39+
expect.stringContaining('Registering component "button" without a namespace is deprecated')
40+
);
41+
});
42+
43+
it('should register a component with namespace', () => {
44+
const component = () => 'test';
45+
registry.register('button', component, { namespace: 'ui' });
46+
47+
expect(registry.has('button', 'ui')).toBe(true);
48+
expect(registry.get('button', 'ui')).toBe(component);
49+
});
50+
51+
it('should not warn when registering with namespace', () => {
52+
const component = () => 'test';
53+
registry.register('button', component, { namespace: 'ui' });
54+
55+
expect(consoleWarnSpy).not.toHaveBeenCalled();
56+
});
57+
});
58+
59+
describe('Namespaced Registration', () => {
60+
it('should register components with the same name in different namespaces', () => {
61+
const gridComponent1 = () => 'grid1';
62+
const gridComponent2 = () => 'grid2';
63+
64+
registry.register('grid', gridComponent1, { namespace: 'plugin-grid' });
65+
registry.register('grid', gridComponent2, { namespace: 'plugin-aggrid' });
66+
67+
expect(registry.get('grid', 'plugin-grid')).toBe(gridComponent1);
68+
expect(registry.get('grid', 'plugin-aggrid')).toBe(gridComponent2);
69+
});
70+
71+
it('should store full type as namespace:type', () => {
72+
const component = () => 'test';
73+
registry.register('button', component, { namespace: 'ui' });
74+
75+
const config = registry.getConfig('button', 'ui');
76+
expect(config?.type).toBe('ui:button');
77+
});
78+
79+
it('should preserve namespace in component config', () => {
80+
const component = () => 'test';
81+
registry.register('button', component, {
82+
namespace: 'ui',
83+
label: 'Button',
84+
category: 'form'
85+
});
86+
87+
const config = registry.getConfig('button', 'ui');
88+
expect(config?.namespace).toBe('ui');
89+
expect(config?.label).toBe('Button');
90+
expect(config?.category).toBe('form');
91+
});
92+
});
93+
94+
describe('Namespace Lookup with Fallback', () => {
95+
it('should fallback to non-namespaced component when namespace lookup fails', () => {
96+
const component = () => 'test';
97+
registry.register('button', component);
98+
99+
// Should find it even when looking with a namespace
100+
expect(registry.get('button', 'ui')).toBe(component);
101+
});
102+
103+
it('should prefer namespaced component over non-namespaced', () => {
104+
const component1 = () => 'non-namespaced';
105+
const component2 = () => 'namespaced';
106+
107+
registry.register('button', component1);
108+
registry.register('button', component2, { namespace: 'ui' });
109+
110+
// When searching with namespace, should get namespaced version
111+
expect(registry.get('button', 'ui')).toBe(component2);
112+
113+
// When searching without namespace, should get non-namespaced version
114+
expect(registry.get('button')).toBe(component1);
115+
});
116+
117+
it('should return undefined when component not found in any namespace', () => {
118+
expect(registry.get('nonexistent', 'ui')).toBeUndefined();
119+
expect(registry.get('nonexistent')).toBeUndefined();
120+
});
121+
});
122+
123+
describe('has() method', () => {
124+
it('should check existence with namespace', () => {
125+
const component = () => 'test';
126+
registry.register('button', component, { namespace: 'ui' });
127+
128+
expect(registry.has('button', 'ui')).toBe(true);
129+
expect(registry.has('button', 'other')).toBe(false);
130+
});
131+
132+
it('should fallback to non-namespaced check', () => {
133+
const component = () => 'test';
134+
registry.register('button', component);
135+
136+
expect(registry.has('button')).toBe(true);
137+
expect(registry.has('button', 'ui')).toBe(true); // fallback
138+
});
139+
});
140+
141+
describe('getConfig() method', () => {
142+
it('should get config with namespace', () => {
143+
const component = () => 'test';
144+
registry.register('button', component, {
145+
namespace: 'ui',
146+
label: 'Button'
147+
});
148+
149+
const config = registry.getConfig('button', 'ui');
150+
expect(config).toBeDefined();
151+
expect(config?.component).toBe(component);
152+
expect(config?.label).toBe('Button');
153+
});
154+
155+
it('should fallback to non-namespaced config', () => {
156+
const component = () => 'test';
157+
registry.register('button', component, { label: 'Button' });
158+
159+
const config = registry.getConfig('button', 'ui');
160+
expect(config).toBeDefined();
161+
expect(config?.component).toBe(component);
162+
});
163+
});
164+
165+
describe('getAllTypes() and getAllConfigs()', () => {
166+
it('should return all registered types including namespaced ones', () => {
167+
registry.register('button', () => 'b1');
168+
registry.register('input', () => 'i1', { namespace: 'ui' });
169+
registry.register('grid', () => 'g1', { namespace: 'plugin-grid' });
170+
171+
const types = registry.getAllTypes();
172+
expect(types).toContain('button');
173+
expect(types).toContain('ui:input');
174+
expect(types).toContain('plugin-grid:grid');
175+
expect(types).toHaveLength(3);
176+
});
177+
178+
it('should return all configs', () => {
179+
registry.register('button', () => 'b1', { label: 'Button' });
180+
registry.register('input', () => 'i1', {
181+
namespace: 'ui',
182+
label: 'Input'
183+
});
184+
185+
const configs = registry.getAllConfigs();
186+
expect(configs).toHaveLength(2);
187+
expect(configs.map(c => c.type)).toContain('button');
188+
expect(configs.map(c => c.type)).toContain('ui:input');
189+
});
190+
});
191+
192+
describe('Conflict Prevention', () => {
193+
it('should allow same type name in different namespaces', () => {
194+
const grid1 = () => 'grid-plugin-1';
195+
const grid2 = () => 'grid-plugin-2';
196+
const grid3 = () => 'aggrid-plugin';
197+
198+
registry.register('grid', grid1, { namespace: 'plugin-grid' });
199+
registry.register('grid', grid2, { namespace: 'plugin-view' });
200+
registry.register('grid', grid3, { namespace: 'plugin-aggrid' });
201+
202+
expect(registry.get('grid', 'plugin-grid')).toBe(grid1);
203+
expect(registry.get('grid', 'plugin-view')).toBe(grid2);
204+
expect(registry.get('grid', 'plugin-aggrid')).toBe(grid3);
205+
});
206+
207+
it('should handle complex namespace names', () => {
208+
const component = () => 'test';
209+
registry.register('table', component, { namespace: 'plugin-advanced-grid' });
210+
211+
expect(registry.get('table', 'plugin-advanced-grid')).toBe(component);
212+
expect(registry.getConfig('table', 'plugin-advanced-grid')?.type).toBe('plugin-advanced-grid:table');
213+
});
214+
});
215+
216+
describe('Backward Compatibility', () => {
217+
it('should maintain compatibility with existing non-namespaced code', () => {
218+
const component = () => 'test';
219+
220+
// Old-style registration
221+
registry.register('button', component);
222+
223+
// Old-style retrieval should still work
224+
expect(registry.get('button')).toBe(component);
225+
expect(registry.has('button')).toBe(true);
226+
expect(registry.getConfig('button')).toBeDefined();
227+
});
228+
229+
it('should support mixed namespaced and non-namespaced registrations', () => {
230+
const oldButton = () => 'old';
231+
const newButton = () => 'new';
232+
233+
registry.register('button-old', oldButton);
234+
registry.register('button-new', newButton, { namespace: 'ui' });
235+
236+
expect(registry.get('button-old')).toBe(oldButton);
237+
expect(registry.get('button-new', 'ui')).toBe(newButton);
238+
});
239+
});
240+
241+
describe('Edge Cases', () => {
242+
it('should handle empty namespace string', () => {
243+
const component = () => 'test';
244+
registry.register('button', component, { namespace: '' });
245+
246+
// Empty namespace should be treated as no namespace
247+
expect(registry.get('button')).toBe(component);
248+
});
249+
250+
it('should handle namespace with special characters', () => {
251+
const component = () => 'test';
252+
registry.register('button', component, { namespace: 'plugin-my-custom' });
253+
254+
expect(registry.get('button', 'plugin-my-custom')).toBe(component);
255+
});
256+
257+
it('should handle undefined meta', () => {
258+
const component = () => 'test';
259+
registry.register('button', component, undefined);
260+
261+
expect(registry.get('button')).toBe(component);
262+
});
263+
});
264+
});

0 commit comments

Comments
 (0)