diff --git a/ROADMAP.md b/ROADMAP.md index 988d7adde..74aa89131 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -79,26 +79,26 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind **Authentication & Console Gaps (P0):** - ✅ Authentication package (@object-ui/auth with AuthProvider, useAuth, AuthGuard, forms) -- 🔲 Console integration (connect auth to routes, DataSource, and user context) -- 🔲 Session management & token injection into @objectstack/client -- 🔲 System admin UIs (sys_user, sys_org, sys_role, sys_audit_log) +- ✅ Console integration (auth routes, AuthGuard, UserMenu in sidebar, real auth session) +- ✅ Session management & token injection into @objectstack/client +- ✅ System admin UIs (sys_user, sys_org, sys_role, sys_audit_log) **ObjectOS Integration Gaps (P1):** - ✅ Multi-tenant architecture support (@object-ui/tenant) - ✅ RBAC integration (object/field/row-level permissions) (@object-ui/permissions) -- 🔲 System objects (sys_user, sys_org, sys_role, sys_permission, sys_audit_log) +- ✅ System objects (sys_user, sys_org, sys_role, sys_permission, sys_audit_log) - ✅ Workflow engine integration - 🔲 Real-time collaboration (WebSocket, presence, comments) **@objectstack/client Integration Gaps (P1):** -- 🔲 Dynamic app loading from server metadata via `adapter.getApp()` -- 🔲 Widget manifest system for runtime widget registration +- ✅ Dynamic app loading from server metadata via `adapter.getApp()` + `useDynamicApp` hook +- ✅ Widget manifest system for runtime widget registration (WidgetManifest + WidgetRegistry) - 🔲 Formula functions in expression engine (SUM, AVG, TODAY, NOW, IF) -- 🔲 Schema hot-reload via cache invalidation +- ✅ Schema hot-reload via cache invalidation (`useDynamicApp.refresh()`) - 🔲 File upload integration via client file API **Spec Alignment Gaps (P1):** -- 🔲 Widget System (WidgetManifest, dynamic loading) +- ✅ Widget System (WidgetManifest, WidgetRegistry, dynamic loading) - 🔲 Formula functions (SUM, AVG, TODAY, NOW, IF) - 🔲 Report export (PDF, Excel) @@ -162,19 +162,19 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - [x] Implement authenticated fetch wrapper for @objectstack/client token injection **Phase 2 — Console Integration (Week 3-4):** -- [ ] Add /login, /register, /forgot-password routes to Console -- [ ] Wrap app routes with AuthGuard (redirect unauthenticated users) -- [ ] Connect AuthProvider → ExpressionProvider → PermissionProvider chain -- [ ] Add UserMenu to AppHeader (profile, settings, sign out) -- [ ] Replace hardcoded user context with real auth session +- [x] Add /login, /register, /forgot-password routes to Console +- [x] Wrap app routes with AuthGuard (redirect unauthenticated users) +- [x] Connect AuthProvider → ExpressionProvider → PermissionProvider chain +- [x] Add UserMenu to AppSidebar footer (profile, settings, sign out) +- [x] Replace hardcoded user context with real auth session **Phase 3 — System Administration (Week 5-6):** -- [ ] Define system objects (sys_user, sys_org, sys_role, sys_permission, sys_audit_log) -- [ ] Build user management page (reuse plugin-grid + plugin-form) -- [ ] Build organization management page with member management -- [ ] Build role management page with permission assignment matrix -- [ ] Build user profile page (profile edit, password change) -- [ ] Build audit log viewer (read-only grid) +- [x] Define system objects (sys_user, sys_org, sys_role, sys_permission, sys_audit_log) +- [x] Build user management page (reuse plugin-grid + plugin-form) +- [x] Build organization management page with member management +- [x] Build role management page with permission assignment matrix +- [x] Build user profile page (profile edit, password change) +- [x] Build audit log viewer (read-only grid) #### 1.6 @objectstack/client Low-Code Integration (3 weeks) **Target:** Validate and enhance client SDK integration for full low-code platform capability @@ -182,27 +182,27 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind > 📄 Full evaluation: [OBJECTSTACK_CLIENT_EVALUATION.md](./OBJECTSTACK_CLIENT_EVALUATION.md) **Dynamic App Loading (Week 1):** -- [ ] Implement dynamic app configuration loading via `adapter.getApp(appId)` -- [ ] Add server-side schema fetching with fallback to static config -- [ ] Schema hot-reload via MetadataCache invalidation + re-render +- [x] Implement dynamic app configuration loading via `adapter.getApp(appId)` +- [x] Add server-side schema fetching with fallback to static config (`useDynamicApp` hook) +- [x] Schema hot-reload via MetadataCache invalidation + re-render (`refresh()`) **Widget System Foundation (Week 2):** -- [ ] Define WidgetManifest interface for runtime widget registration -- [ ] Implement plugin auto-discovery from server metadata -- [ ] Custom widget registry for user-defined components +- [x] Define WidgetManifest interface for runtime widget registration +- [x] Implement plugin auto-discovery from server metadata (WidgetRegistry) +- [x] Custom widget registry for user-defined components **Data Integration Hardening (Week 3):** - [ ] File upload integration via extended ObjectStackAdapter -- [ ] Connection resilience testing (auto-reconnect, error recovery) +- [x] Connection resilience testing (auto-reconnect, error recovery) - [ ] End-to-end data flow validation with live ObjectStack backend **Deliverables:** - @object-ui/auth package ✅ -- Console login / register / password reset pages -- System administration pages (users, orgs, roles, audit logs) -- Dynamic app loading from server metadata -- Widget manifest system -- @objectstack/client integration hardening +- Console login / register / password reset pages ✅ +- System administration pages (users, orgs, roles, audit logs) ✅ +- Dynamic app loading from server metadata ✅ +- Widget manifest system ✅ +- @objectstack/client integration hardening (in progress) **Q1 Milestone:** - **v0.6.0 Release (March 2026):** Infrastructure Complete + Auth Foundation + Client Integration Validated diff --git a/packages/auth/src/__tests__/AuthProvider.test.tsx b/packages/auth/src/__tests__/AuthProvider.test.tsx new file mode 100644 index 000000000..1fe419cae --- /dev/null +++ b/packages/auth/src/__tests__/AuthProvider.test.tsx @@ -0,0 +1,322 @@ +/** + * Tests for AuthProvider, useAuth, and AuthGuard + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AuthProvider } from '../AuthProvider'; +import { useAuth } from '../useAuth'; +import { AuthGuard } from '../AuthGuard'; +import type { AuthClient } from '../types'; + +function createMockClient(overrides: Partial = {}): AuthClient { + return { + signIn: vi.fn().mockResolvedValue({ + user: { id: '1', name: 'Test User', email: 'test@test.com' }, + session: { token: 'tok123' }, + }), + signUp: vi.fn().mockResolvedValue({ + user: { id: '2', name: 'New User', email: 'new@test.com' }, + session: { token: 'tok456' }, + }), + signOut: vi.fn().mockResolvedValue(undefined), + getSession: vi.fn().mockResolvedValue(null), + forgotPassword: vi.fn().mockResolvedValue(undefined), + resetPassword: vi.fn().mockResolvedValue(undefined), + updateUser: vi.fn().mockResolvedValue({ id: '1', name: 'Updated', email: 'test@test.com' }), + ...overrides, + }; +} + +function TestConsumer() { + const { user, isAuthenticated, isLoading, error } = useAuth(); + return ( +
+ {String(isLoading)} + {String(isAuthenticated)} + {user?.name ?? 'none'} + {error?.message ?? 'none'} +
+ ); +} + +describe('AuthProvider', () => { + it('starts in loading state and resolves to unauthenticated when no session', async () => { + const client = createMockClient(); + + render( + + + , + ); + + // Wait for loading to finish + await waitFor(() => { + expect(screen.getByTestId('loading').textContent).toBe('false'); + }); + + expect(screen.getByTestId('authenticated').textContent).toBe('false'); + expect(screen.getByTestId('user-name').textContent).toBe('none'); + }); + + it('resolves to authenticated when session exists', async () => { + const client = createMockClient({ + getSession: vi.fn().mockResolvedValue({ + user: { id: '1', name: 'Alice', email: 'alice@test.com' }, + session: { token: 'session-tok' }, + }), + }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('loading').textContent).toBe('false'); + }); + + expect(screen.getByTestId('authenticated').textContent).toBe('true'); + expect(screen.getByTestId('user-name').textContent).toBe('Alice'); + }); + + it('sets error state when getSession fails', async () => { + const client = createMockClient({ + getSession: vi.fn().mockRejectedValue(new Error('Session fetch failed')), + }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('loading').textContent).toBe('false'); + }); + + expect(screen.getByTestId('error').textContent).toBe('Session fetch failed'); + }); + + it('calls onAuthStateChange when auth state changes', async () => { + const onAuthStateChange = vi.fn(); + const client = createMockClient(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('loading').textContent).toBe('false'); + }); + + expect(onAuthStateChange).toHaveBeenCalled(); + }); +}); + +describe('useAuth', () => { + it('returns safe defaults when used outside AuthProvider', () => { + function OutsideConsumer() { + const auth = useAuth(); + return {String(auth.isAuthenticated)}; + } + + render(); + expect(screen.getByTestId('outside').textContent).toBe('false'); + }); + + it('signIn updates user state', async () => { + const client = createMockClient(); + + function SignInConsumer() { + const { signIn, user, isAuthenticated } = useAuth(); + return ( +
+ + {String(isAuthenticated)} + {user?.name ?? 'none'} +
+ ); + } + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('auth').textContent).toBe('false'); + }); + + await act(async () => { + await userEvent.click(screen.getByText('Sign In')); + }); + + await waitFor(() => { + expect(screen.getByTestId('auth').textContent).toBe('true'); + }); + expect(screen.getByTestId('name').textContent).toBe('Test User'); + expect(client.signIn).toHaveBeenCalledWith({ email: 'test@test.com', password: 'pass' }); + }); + + it('signOut clears user state', async () => { + const client = createMockClient({ + getSession: vi.fn().mockResolvedValue({ + user: { id: '1', name: 'Test', email: 'test@test.com' }, + session: { token: 'tok' }, + }), + }); + + function SignOutConsumer() { + const { signOut, isAuthenticated } = useAuth(); + return ( +
+ + {String(isAuthenticated)} +
+ ); + } + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('auth').textContent).toBe('true'); + }); + + await act(async () => { + await userEvent.click(screen.getByText('Sign Out')); + }); + + await waitFor(() => { + expect(screen.getByTestId('auth').textContent).toBe('false'); + }); + }); +}); + +describe('AuthGuard', () => { + it('shows loading fallback while loading', () => { + const client = createMockClient({ + getSession: () => new Promise(() => {}), // Never resolves + }); + + render( + + Loading...} fallback={Not auth}> + Protected + + , + ); + + expect(screen.getByText('Loading...')).toBeTruthy(); + }); + + it('shows fallback when not authenticated', async () => { + const client = createMockClient(); + + render( + + Not authenticated}> + Protected content + + , + ); + + await waitFor(() => { + expect(screen.getByText('Not authenticated')).toBeTruthy(); + }); + }); + + it('shows children when authenticated', async () => { + const client = createMockClient({ + getSession: vi.fn().mockResolvedValue({ + user: { id: '1', name: 'Test', email: 'test@test.com' }, + session: { token: 'tok' }, + }), + }); + + render( + + Not auth}> + Protected content + + , + ); + + await waitFor(() => { + expect(screen.getByText('Protected content')).toBeTruthy(); + }); + }); + + it('enforces role requirements', async () => { + const client = createMockClient({ + getSession: vi.fn().mockResolvedValue({ + user: { id: '1', name: 'Test', email: 'test@test.com', role: 'member' }, + session: { token: 'tok' }, + }), + }); + + render( + + Access denied}> + Admin content + + , + ); + + await waitFor(() => { + expect(screen.getByText('Access denied')).toBeTruthy(); + }); + }); + + it('allows access when user has required role', async () => { + const client = createMockClient({ + getSession: vi.fn().mockResolvedValue({ + user: { id: '1', name: 'Admin', email: 'admin@test.com', role: 'admin' }, + session: { token: 'tok' }, + }), + }); + + render( + + Access denied}> + Admin content + + , + ); + + await waitFor(() => { + expect(screen.getByText('Admin content')).toBeTruthy(); + }); + }); + + it('allows access when user has one of the required roles', async () => { + const client = createMockClient({ + getSession: vi.fn().mockResolvedValue({ + user: { id: '1', name: 'Manager', email: 'mgr@test.com', roles: ['manager', 'viewer'] }, + session: { token: 'tok' }, + }), + }); + + render( + + Access denied}> + Manager content + + , + ); + + await waitFor(() => { + expect(screen.getByText('Manager content')).toBeTruthy(); + }); + }); +}); diff --git a/packages/auth/src/__tests__/createAuthClient.test.ts b/packages/auth/src/__tests__/createAuthClient.test.ts new file mode 100644 index 000000000..8b81a0abd --- /dev/null +++ b/packages/auth/src/__tests__/createAuthClient.test.ts @@ -0,0 +1,181 @@ +/** + * Tests for createAuthClient + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createAuthClient } from '../createAuthClient'; +import type { AuthClient } from '../types'; + +describe('createAuthClient', () => { + let client: AuthClient; + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + client = createAuthClient({ baseURL: '/api/auth', fetchFn: mockFetch }); + }); + + it('creates a client with all expected methods', () => { + expect(client).toHaveProperty('signIn'); + expect(client).toHaveProperty('signUp'); + expect(client).toHaveProperty('signOut'); + expect(client).toHaveProperty('getSession'); + expect(client).toHaveProperty('forgotPassword'); + expect(client).toHaveProperty('resetPassword'); + expect(client).toHaveProperty('updateUser'); + }); + + it('signIn sends POST to /sign-in/email', async () => { + const mockResponse = { + user: { id: '1', name: 'Test', email: 'test@test.com' }, + session: { token: 'tok123' }, + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const result = await client.signIn({ email: 'test@test.com', password: 'pass123' }); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/auth/sign-in/email', + expect.objectContaining({ + method: 'POST', + credentials: 'include', + body: JSON.stringify({ email: 'test@test.com', password: 'pass123' }), + }), + ); + expect(result.user.email).toBe('test@test.com'); + expect(result.session.token).toBe('tok123'); + }); + + it('signUp sends POST to /sign-up/email', async () => { + const mockResponse = { + user: { id: '2', name: 'New User', email: 'new@test.com' }, + session: { token: 'tok456' }, + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const result = await client.signUp({ name: 'New User', email: 'new@test.com', password: 'pass123' }); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/auth/sign-up/email', + expect.objectContaining({ method: 'POST' }), + ); + expect(result.user.name).toBe('New User'); + }); + + it('signOut sends POST to /sign-out', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + await client.signOut(); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/auth/sign-out', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('getSession sends GET to /get-session', async () => { + const mockSession = { + user: { id: '1', name: 'Test', email: 'test@test.com' }, + session: { token: 'tok789' }, + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSession), + }); + + const result = await client.getSession(); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/auth/get-session', + expect.objectContaining({ method: 'GET' }), + ); + expect(result?.user.id).toBe('1'); + }); + + it('getSession returns null on failure', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await client.getSession(); + expect(result).toBeNull(); + }); + + it('forgotPassword sends POST to /forgot-password', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + await client.forgotPassword('test@test.com'); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/auth/forgot-password', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'test@test.com' }), + }), + ); + }); + + it('resetPassword sends POST to /reset-password', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + await client.resetPassword('token123', 'newpass'); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/auth/reset-password', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ token: 'token123', newPassword: 'newpass' }), + }), + ); + }); + + it('throws error with server message on non-OK response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: () => Promise.resolve({ message: 'Invalid credentials' }), + }); + + await expect(client.signIn({ email: 'x', password: 'y' })).rejects.toThrow('Invalid credentials'); + }); + + it('throws generic error when response has no message', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.reject(new Error('parse error')), + }); + + await expect(client.signIn({ email: 'x', password: 'y' })).rejects.toThrow( + 'Auth request failed with status 500', + ); + }); + + it('updateUser sends POST to /update-user and returns user', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ user: { id: '1', name: 'Updated', email: 'test@test.com' } }), + }); + + const result = await client.updateUser({ name: 'Updated' }); + + expect(result.name).toBe('Updated'); + expect(mockFetch).toHaveBeenCalledWith( + '/api/auth/update-user', + expect.objectContaining({ method: 'POST' }), + ); + }); +}); diff --git a/packages/auth/src/__tests__/createAuthenticatedFetch.test.ts b/packages/auth/src/__tests__/createAuthenticatedFetch.test.ts new file mode 100644 index 000000000..96a891e96 --- /dev/null +++ b/packages/auth/src/__tests__/createAuthenticatedFetch.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for createAuthenticatedFetch + */ + +import { describe, it, expect, vi } from 'vitest'; +import { createAuthenticatedFetch } from '../createAuthenticatedFetch'; +import type { AuthClient } from '../types'; + +describe('createAuthenticatedFetch', () => { + it('injects Authorization header when session exists', async () => { + const mockClient: AuthClient = { + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + getSession: vi.fn().mockResolvedValue({ + user: { id: '1', name: 'Test', email: 'test@test.com' }, + session: { token: 'bearer-token-123' }, + }), + forgotPassword: vi.fn(), + resetPassword: vi.fn(), + updateUser: vi.fn(), + }; + + const authenticatedFetch = createAuthenticatedFetch(mockClient); + + const originalFetch = globalThis.fetch; + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + globalThis.fetch = mockFetch; + + try { + await authenticatedFetch('https://api.example.com/data'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.objectContaining({ + headers: expect.any(Headers), + }), + ); + + const calledHeaders = mockFetch.mock.calls[0][1].headers as Headers; + expect(calledHeaders.get('Authorization')).toBe('Bearer bearer-token-123'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('does not inject Authorization header when no session', async () => { + const mockClient: AuthClient = { + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + getSession: vi.fn().mockResolvedValue(null), + forgotPassword: vi.fn(), + resetPassword: vi.fn(), + updateUser: vi.fn(), + }; + + const authenticatedFetch = createAuthenticatedFetch(mockClient); + + const originalFetch = globalThis.fetch; + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + globalThis.fetch = mockFetch; + + try { + await authenticatedFetch('https://api.example.com/data'); + + const calledHeaders = mockFetch.mock.calls[0][1].headers as Headers; + expect(calledHeaders.get('Authorization')).toBeNull(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('preserves existing request headers', async () => { + const mockClient: AuthClient = { + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + getSession: vi.fn().mockResolvedValue({ + user: { id: '1', name: 'Test', email: 'test@test.com' }, + session: { token: 'tok' }, + }), + forgotPassword: vi.fn(), + resetPassword: vi.fn(), + updateUser: vi.fn(), + }; + + const authenticatedFetch = createAuthenticatedFetch(mockClient); + + const originalFetch = globalThis.fetch; + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + globalThis.fetch = mockFetch; + + try { + await authenticatedFetch('https://api.example.com/data', { + headers: { 'X-Custom': 'value' }, + }); + + const calledHeaders = mockFetch.mock.calls[0][1].headers as Headers; + expect(calledHeaders.get('X-Custom')).toBe('value'); + expect(calledHeaders.get('Authorization')).toBe('Bearer tok'); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/packages/auth/src/__tests__/types.test.ts b/packages/auth/src/__tests__/types.test.ts new file mode 100644 index 000000000..bb83f4600 --- /dev/null +++ b/packages/auth/src/__tests__/types.test.ts @@ -0,0 +1,36 @@ +/** + * Tests for auth types utility functions + */ + +import { describe, it, expect } from 'vitest'; +import { getUserInitials } from '../types'; + +describe('getUserInitials', () => { + it('returns initials from full name', () => { + expect(getUserInitials({ name: 'John Doe', email: 'j@test.com' })).toBe('JD'); + }); + + it('returns single initial for single-word name', () => { + expect(getUserInitials({ name: 'Admin', email: 'a@test.com' })).toBe('A'); + }); + + it('returns at most 2 characters for long names', () => { + expect(getUserInitials({ name: 'John Michael Doe', email: 'j@test.com' })).toBe('JM'); + }); + + it('returns first letter of email when name is empty', () => { + expect(getUserInitials({ name: '', email: 'alice@example.com' })).toBe('A'); + }); + + it('returns "?" for null user', () => { + expect(getUserInitials(null)).toBe('?'); + }); + + it('returns "?" for undefined user', () => { + expect(getUserInitials(undefined)).toBe('?'); + }); + + it('uppercases initials', () => { + expect(getUserInitials({ name: 'jane smith', email: 'j@test.com' })).toBe('JS'); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b93951106..97efff36f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export type { SchemaNode, ComponentRendererProps } from './types/index.js'; export * from './registry/Registry.js'; export * from './registry/PluginSystem.js'; export * from './registry/PluginScopeImpl.js'; +export * from './registry/WidgetRegistry.js'; export * from './validation/index.js'; export * from './builder/schema-builder.js'; export * from './utils/filter-converter.js'; diff --git a/packages/core/src/registry/WidgetRegistry.ts b/packages/core/src/registry/WidgetRegistry.ts new file mode 100644 index 000000000..adcc1ae5c --- /dev/null +++ b/packages/core/src/registry/WidgetRegistry.ts @@ -0,0 +1,304 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * WidgetRegistry - Runtime widget management with auto-discovery. + * + * Provides registration, loading, and lookup of runtime widgets + * described by WidgetManifest objects. Widgets can be loaded from + * ES module URLs, provided inline, or resolved from the component registry. + * + * @module + */ + +import type { + WidgetManifest, + ResolvedWidget, + WidgetRegistryEvent, + WidgetRegistryListener, +} from '@object-ui/types'; +import type { Registry } from './Registry.js'; + +/** + * Options for creating a WidgetRegistry instance. + */ +export interface WidgetRegistryOptions { + /** + * Component registry to sync loaded widgets into. + * When a widget is loaded, its component is also registered here. + */ + componentRegistry?: Registry; +} + +/** + * WidgetRegistry manages runtime-loadable widgets described by manifests. + * + * @example + * ```ts + * const widgets = new WidgetRegistry({ componentRegistry: registry }); + * + * widgets.register({ + * name: 'custom-chart', + * version: '1.0.0', + * type: 'chart', + * label: 'Custom Chart', + * source: { type: 'module', url: '/widgets/chart.js' }, + * }); + * + * const resolved = await widgets.load('custom-chart'); + * ``` + */ +export class WidgetRegistry { + private manifests = new Map(); + private resolved = new Map(); + private listeners = new Set(); + private componentRegistry?: Registry; + + constructor(options: WidgetRegistryOptions = {}) { + this.componentRegistry = options.componentRegistry; + } + + /** + * Register a widget manifest. + * Does not load the widget; call `load()` to resolve it. + */ + register(manifest: WidgetManifest): void { + this.manifests.set(manifest.name, manifest); + this.emit({ type: 'widget:registered', widget: manifest }); + } + + /** + * Register multiple widget manifests at once. + */ + registerAll(manifests: WidgetManifest[]): void { + for (const manifest of manifests) { + this.register(manifest); + } + } + + /** + * Unregister a widget by name. + */ + unregister(name: string): boolean { + const existed = this.manifests.delete(name); + this.resolved.delete(name); + if (existed) { + this.emit({ type: 'widget:unregistered', name }); + } + return existed; + } + + /** + * Get a widget manifest by name. + */ + getManifest(name: string): WidgetManifest | undefined { + return this.manifests.get(name); + } + + /** + * Get all registered widget manifests. + */ + getAllManifests(): WidgetManifest[] { + return Array.from(this.manifests.values()); + } + + /** + * Get manifests filtered by category. + */ + getByCategory(category: string): WidgetManifest[] { + return this.getAllManifests().filter((m) => m.category === category); + } + + /** + * Check if a widget is registered. + */ + has(name: string): boolean { + return this.manifests.has(name); + } + + /** + * Check if a widget has been loaded (resolved). + */ + isLoaded(name: string): boolean { + return this.resolved.has(name); + } + + /** + * Load (resolve) a widget by name. + * If already loaded, returns the cached resolved widget. + * + * @throws Error if the widget is not registered or fails to load. + */ + async load(name: string): Promise { + // Return cached if already loaded + const cached = this.resolved.get(name); + if (cached) return cached; + + const manifest = this.manifests.get(name); + if (!manifest) { + const error = new Error(`Widget "${name}" is not registered`); + this.emit({ type: 'widget:error', name, error }); + throw error; + } + + // Resolve dependencies first + if (manifest.dependencies) { + for (const dep of manifest.dependencies) { + if (!this.isLoaded(dep)) { + await this.load(dep); + } + } + } + + try { + const component = await this.resolveComponent(manifest); + + const resolved: ResolvedWidget = { + manifest, + component, + loadedAt: Date.now(), + }; + + this.resolved.set(name, resolved); + + // Sync to component registry if available + if (this.componentRegistry) { + this.componentRegistry.register(manifest.type, component as any, { + label: manifest.label, + icon: manifest.icon, + category: manifest.category, + inputs: manifest.inputs?.map((input) => ({ + name: input.name, + type: input.type, + label: input.label, + defaultValue: input.defaultValue, + required: input.required, + enum: input.options, + description: input.description, + advanced: input.advanced, + })), + defaultProps: manifest.defaultProps as Record, + isContainer: manifest.isContainer, + }); + } + + this.emit({ type: 'widget:loaded', widget: resolved }); + return resolved; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.emit({ type: 'widget:error', name, error }); + throw error; + } + } + + /** + * Load all registered widgets. + * Returns an array of results (settled promises). + */ + async loadAll(): Promise> { + const results: Array<{ name: string; result: ResolvedWidget | Error }> = []; + + for (const [name] of this.manifests) { + try { + const resolved = await this.load(name); + results.push({ name, result: resolved }); + } catch (err) { + results.push({ name, result: err instanceof Error ? err : new Error(String(err)) }); + } + } + + return results; + } + + /** + * Subscribe to widget registry events. + * @returns Unsubscribe function. + */ + on(listener: WidgetRegistryListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Clear all registered and loaded widgets. + */ + clear(): void { + this.manifests.clear(); + this.resolved.clear(); + } + + /** + * Get registry statistics. + */ + getStats(): { registered: number; loaded: number; categories: string[] } { + const categories = new Set(); + for (const m of this.manifests.values()) { + if (m.category) categories.add(m.category); + } + return { + registered: this.manifests.size, + loaded: this.resolved.size, + categories: Array.from(categories), + }; + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private async resolveComponent(manifest: WidgetManifest): Promise { + const { source } = manifest; + + switch (source.type) { + case 'inline': + return source.component; + + case 'registry': { + if (!this.componentRegistry) { + throw new Error( + `Widget "${manifest.name}" uses registry source but no component registry is configured`, + ); + } + const component = this.componentRegistry.get(source.registryKey); + if (!component) { + throw new Error( + `Widget "${manifest.name}" references registry key "${source.registryKey}" which is not registered`, + ); + } + return component; + } + + case 'module': { + const mod = await import(/* @vite-ignore */ source.url); + const exportName = source.exportName ?? 'default'; + const component = mod[exportName]; + if (!component) { + throw new Error( + `Widget "${manifest.name}" module at "${source.url}" does not export "${exportName}"`, + ); + } + return component; + } + + default: + throw new Error(`Unknown widget source type for "${manifest.name}"`); + } + } + + private emit(event: WidgetRegistryEvent): void { + for (const listener of this.listeners) { + try { + listener(event); + } catch { + // Swallow listener errors to prevent cascading failures + } + } + } +} diff --git a/packages/core/src/registry/__tests__/WidgetRegistry.test.ts b/packages/core/src/registry/__tests__/WidgetRegistry.test.ts new file mode 100644 index 000000000..9284e7182 --- /dev/null +++ b/packages/core/src/registry/__tests__/WidgetRegistry.test.ts @@ -0,0 +1,321 @@ +/** + * Tests for WidgetRegistry + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WidgetRegistry } from '../WidgetRegistry'; +import { Registry } from '../Registry'; +import type { WidgetManifest, WidgetRegistryEvent } from '@object-ui/types'; + +function createManifest(overrides: Partial = {}): WidgetManifest { + return { + name: 'test-widget', + version: '1.0.0', + type: 'test', + label: 'Test Widget', + source: { type: 'inline', component: () => null }, + ...overrides, + }; +} + +describe('WidgetRegistry', () => { + let widgetRegistry: WidgetRegistry; + + beforeEach(() => { + widgetRegistry = new WidgetRegistry(); + }); + + describe('register / unregister', () => { + it('registers a widget manifest', () => { + const manifest = createManifest(); + widgetRegistry.register(manifest); + expect(widgetRegistry.has('test-widget')).toBe(true); + expect(widgetRegistry.getManifest('test-widget')).toBe(manifest); + }); + + it('registers multiple manifests at once', () => { + widgetRegistry.registerAll([ + createManifest({ name: 'a', type: 'a' }), + createManifest({ name: 'b', type: 'b' }), + ]); + expect(widgetRegistry.has('a')).toBe(true); + expect(widgetRegistry.has('b')).toBe(true); + }); + + it('unregisters a widget', () => { + widgetRegistry.register(createManifest()); + expect(widgetRegistry.unregister('test-widget')).toBe(true); + expect(widgetRegistry.has('test-widget')).toBe(false); + }); + + it('returns false when unregistering a non-existent widget', () => { + expect(widgetRegistry.unregister('nope')).toBe(false); + }); + }); + + describe('getAllManifests / getByCategory', () => { + it('returns all manifests', () => { + widgetRegistry.registerAll([ + createManifest({ name: 'a', type: 'a' }), + createManifest({ name: 'b', type: 'b' }), + ]); + expect(widgetRegistry.getAllManifests()).toHaveLength(2); + }); + + it('filters by category', () => { + widgetRegistry.registerAll([ + createManifest({ name: 'chart-1', type: 'c1', category: 'charts' }), + createManifest({ name: 'form-1', type: 'f1', category: 'forms' }), + createManifest({ name: 'chart-2', type: 'c2', category: 'charts' }), + ]); + expect(widgetRegistry.getByCategory('charts')).toHaveLength(2); + expect(widgetRegistry.getByCategory('forms')).toHaveLength(1); + expect(widgetRegistry.getByCategory('unknown')).toHaveLength(0); + }); + }); + + describe('load', () => { + it('loads an inline widget', async () => { + const component = () => null; + widgetRegistry.register( + createManifest({ source: { type: 'inline', component } }), + ); + + const resolved = await widgetRegistry.load('test-widget'); + expect(resolved.component).toBe(component); + expect(resolved.manifest.name).toBe('test-widget'); + expect(resolved.loadedAt).toBeGreaterThan(0); + expect(widgetRegistry.isLoaded('test-widget')).toBe(true); + }); + + it('returns cached resolved widget on subsequent loads', async () => { + widgetRegistry.register(createManifest()); + const first = await widgetRegistry.load('test-widget'); + const second = await widgetRegistry.load('test-widget'); + expect(first).toBe(second); + }); + + it('throws when loading an unregistered widget', async () => { + await expect(widgetRegistry.load('nope')).rejects.toThrow( + 'Widget "nope" is not registered', + ); + }); + + it('loads a widget from the component registry', async () => { + const componentRegistry = new Registry(); + const component = () => null; + componentRegistry.register('existing-type', component); + + const reg = new WidgetRegistry({ componentRegistry }); + reg.register( + createManifest({ + source: { type: 'registry', registryKey: 'existing-type' }, + }), + ); + + const resolved = await reg.load('test-widget'); + expect(resolved.component).toBe(component); + }); + + it('throws when registry key is not found', async () => { + const componentRegistry = new Registry(); + const reg = new WidgetRegistry({ componentRegistry }); + reg.register( + createManifest({ + source: { type: 'registry', registryKey: 'missing-key' }, + }), + ); + + await expect(reg.load('test-widget')).rejects.toThrow( + 'references registry key "missing-key"', + ); + }); + + it('throws when no component registry is configured for registry source', async () => { + widgetRegistry.register( + createManifest({ + source: { type: 'registry', registryKey: 'something' }, + }), + ); + + await expect(widgetRegistry.load('test-widget')).rejects.toThrow( + 'no component registry is configured', + ); + }); + + it('resolves dependencies before loading', async () => { + const loadOrder: string[] = []; + + widgetRegistry.register( + createManifest({ + name: 'dep-a', + type: 'dep-a', + source: { + type: 'inline', + component: () => { + loadOrder.push('dep-a'); + return null; + }, + }, + }), + ); + + widgetRegistry.register( + createManifest({ + name: 'main', + type: 'main', + dependencies: ['dep-a'], + source: { + type: 'inline', + component: () => { + loadOrder.push('main'); + return null; + }, + }, + }), + ); + + await widgetRegistry.load('main'); + expect(widgetRegistry.isLoaded('dep-a')).toBe(true); + expect(widgetRegistry.isLoaded('main')).toBe(true); + }); + + it('syncs loaded widget to component registry', async () => { + const componentRegistry = new Registry(); + const reg = new WidgetRegistry({ componentRegistry }); + const component = () => null; + + reg.register( + createManifest({ + name: 'sync-test', + type: 'custom-type', + label: 'Sync Test', + category: 'testing', + source: { type: 'inline', component }, + }), + ); + + await reg.load('sync-test'); + + const synced = componentRegistry.get('custom-type'); + expect(synced).toBe(component); + }); + }); + + describe('loadAll', () => { + it('loads all registered widgets', async () => { + widgetRegistry.registerAll([ + createManifest({ name: 'a', type: 'a' }), + createManifest({ name: 'b', type: 'b' }), + ]); + + const results = await widgetRegistry.loadAll(); + expect(results).toHaveLength(2); + expect(results.every((r) => !(r.result instanceof Error))).toBe(true); + }); + + it('captures errors for failed widgets', async () => { + widgetRegistry.register(createManifest({ name: 'good', type: 'good' })); + widgetRegistry.register( + createManifest({ + name: 'bad', + type: 'bad', + source: { type: 'registry', registryKey: 'missing' }, + }), + ); + + const results = await widgetRegistry.loadAll(); + const bad = results.find((r) => r.name === 'bad'); + expect(bad?.result).toBeInstanceOf(Error); + }); + }); + + describe('events', () => { + it('emits widget:registered event', () => { + const events: WidgetRegistryEvent[] = []; + widgetRegistry.on((e) => events.push(e)); + + widgetRegistry.register(createManifest()); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe('widget:registered'); + }); + + it('emits widget:unregistered event', () => { + widgetRegistry.register(createManifest()); + + const events: WidgetRegistryEvent[] = []; + widgetRegistry.on((e) => events.push(e)); + + widgetRegistry.unregister('test-widget'); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe('widget:unregistered'); + }); + + it('emits widget:loaded event', async () => { + widgetRegistry.register(createManifest()); + + const events: WidgetRegistryEvent[] = []; + widgetRegistry.on((e) => events.push(e)); + + await widgetRegistry.load('test-widget'); + + expect(events.some((e) => e.type === 'widget:loaded')).toBe(true); + }); + + it('emits widget:error event on load failure', async () => { + widgetRegistry.register( + createManifest({ + source: { type: 'registry', registryKey: 'missing' }, + }), + ); + + const events: WidgetRegistryEvent[] = []; + widgetRegistry.on((e) => events.push(e)); + + await widgetRegistry.load('test-widget').catch(() => {}); + + expect(events.some((e) => e.type === 'widget:error')).toBe(true); + }); + + it('supports unsubscribing from events', () => { + const handler = vi.fn(); + const unsub = widgetRegistry.on(handler); + + widgetRegistry.register(createManifest({ name: 'a', type: 'a' })); + expect(handler).toHaveBeenCalledTimes(1); + + unsub(); + widgetRegistry.register(createManifest({ name: 'b', type: 'b' })); + expect(handler).toHaveBeenCalledTimes(1); // No new calls + }); + }); + + describe('clear / getStats', () => { + it('clears all widgets', async () => { + widgetRegistry.register(createManifest()); + await widgetRegistry.load('test-widget'); + + widgetRegistry.clear(); + + expect(widgetRegistry.has('test-widget')).toBe(false); + expect(widgetRegistry.isLoaded('test-widget')).toBe(false); + }); + + it('returns correct stats', () => { + widgetRegistry.registerAll([ + createManifest({ name: 'a', type: 'a', category: 'charts' }), + createManifest({ name: 'b', type: 'b', category: 'forms' }), + createManifest({ name: 'c', type: 'c', category: 'charts' }), + ]); + + const stats = widgetRegistry.getStats(); + expect(stats.registered).toBe(3); + expect(stats.loaded).toBe(0); + expect(stats.categories).toContain('charts'); + expect(stats.categories).toContain('forms'); + expect(stats.categories).toHaveLength(2); + }); + }); +}); diff --git a/packages/react/src/hooks/__tests__/useDynamicApp.test.ts b/packages/react/src/hooks/__tests__/useDynamicApp.test.ts new file mode 100644 index 000000000..1cfa5fcb3 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useDynamicApp.test.ts @@ -0,0 +1,161 @@ +/** + * Tests for useDynamicApp hook + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useDynamicApp } from '../useDynamicApp'; + +describe('useDynamicApp', () => { + const staticConfig = { + name: 'crm', + label: 'CRM App', + objects: ['contact', 'account'], + }; + + it('returns static config when no adapter is provided', () => { + const { result } = renderHook(() => + useDynamicApp({ appId: 'crm', staticConfig }), + ); + + expect(result.current.config).toEqual(staticConfig); + expect(result.current.isLoading).toBe(false); + expect(result.current.isServerConfig).toBe(false); + }); + + it('returns null config when neither adapter nor static config provided', () => { + const { result } = renderHook(() => + useDynamicApp({ appId: 'crm' }), + ); + + expect(result.current.config).toBeNull(); + expect(result.current.isLoading).toBe(false); + }); + + it('loads config from server adapter', async () => { + const serverConfig = { name: 'crm', label: 'CRM (Server)', objects: ['contact'] }; + const adapter = { + getApp: vi.fn().mockResolvedValue(serverConfig), + }; + + const { result } = renderHook(() => + useDynamicApp({ appId: 'crm', staticConfig, adapter }), + ); + + // Initially loading + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.config).toEqual(serverConfig); + expect(result.current.isServerConfig).toBe(true); + expect(adapter.getApp).toHaveBeenCalledWith('crm'); + }); + + it('falls back to static config when server returns null', async () => { + const adapter = { + getApp: vi.fn().mockResolvedValue(null), + }; + + const { result } = renderHook(() => + useDynamicApp({ appId: 'crm', staticConfig, adapter }), + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.config).toEqual(staticConfig); + expect(result.current.isServerConfig).toBe(false); + }); + + it('falls back to static config on server error', async () => { + const adapter = { + getApp: vi.fn().mockRejectedValue(new Error('Network error')), + }; + + const { result } = renderHook(() => + useDynamicApp({ appId: 'crm', staticConfig, adapter }), + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.config).toEqual(staticConfig); + expect(result.current.isServerConfig).toBe(false); + expect(result.current.error?.message).toBe('Network error'); + }); + + it('skips loading when enabled is false', () => { + const adapter = { + getApp: vi.fn(), + }; + + const { result } = renderHook(() => + useDynamicApp({ appId: 'crm', staticConfig, adapter, enabled: false }), + ); + + expect(result.current.config).toEqual(staticConfig); + expect(result.current.isLoading).toBe(false); + expect(adapter.getApp).not.toHaveBeenCalled(); + }); + + it('refresh invalidates cache and re-fetches', async () => { + let callCount = 0; + const adapter = { + getApp: vi.fn().mockImplementation(async () => { + callCount++; + return { name: 'crm', version: callCount }; + }), + invalidateCache: vi.fn(), + }; + + const { result } = renderHook(() => + useDynamicApp({ appId: 'crm', adapter }), + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect((result.current.config as any).version).toBe(1); + + await act(async () => { + await result.current.refresh(); + }); + + expect(adapter.invalidateCache).toHaveBeenCalledWith('app:crm'); + expect((result.current.config as any).version).toBe(2); + }); + + it('reloads when appId changes', async () => { + const adapter = { + getApp: vi.fn().mockImplementation(async (appId: string) => { + return { name: appId }; + }), + }; + + const { result, rerender } = renderHook( + ({ appId }: { appId: string }) => + useDynamicApp({ appId, adapter }), + { initialProps: { appId: 'crm' } }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect((result.current.config as any).name).toBe('crm'); + + rerender({ appId: 'erp' }); + + await waitFor(() => { + expect((result.current.config as any).name).toBe('erp'); + }); + + expect(adapter.getApp).toHaveBeenCalledWith('erp'); + }); +}); diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index e47d6430c..8d4f58d40 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -11,4 +11,5 @@ export * from './useActionRunner'; export * from './useNavigationOverlay'; export * from './usePageVariables'; export * from './useViewData'; +export * from './useDynamicApp'; diff --git a/packages/react/src/hooks/useDynamicApp.ts b/packages/react/src/hooks/useDynamicApp.ts new file mode 100644 index 000000000..83824d255 --- /dev/null +++ b/packages/react/src/hooks/useDynamicApp.ts @@ -0,0 +1,142 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; + +/** + * Options for dynamic app configuration loading. + */ +export interface DynamicAppOptions { + /** Application identifier to load */ + appId: string; + /** + * Static (fallback) configuration used when the server does not + * provide an app definition or is unreachable. + */ + staticConfig?: TAppConfig; + /** + * Data source adapter with `getApp` and `invalidateCache` methods. + * Typically an ObjectStackAdapter instance. + */ + adapter?: { + getApp: (appId: string) => Promise; + invalidateCache?: (key?: string) => void; + getObjectSchema?: (objectName: string) => Promise; + }; + /** Whether to attempt loading from the server (default: true) */ + enabled?: boolean; +} + +/** + * Result returned by useDynamicApp. + */ +export interface DynamicAppResult { + /** The resolved app configuration (server or static fallback) */ + config: TAppConfig | null; + /** Whether the configuration is currently loading */ + isLoading: boolean; + /** Error from the most recent load attempt */ + error: Error | null; + /** Whether the config was loaded from the server (vs static fallback) */ + isServerConfig: boolean; + /** Re-fetch the app configuration from the server, invalidating cache */ + refresh: () => Promise; +} + +/** + * React hook for dynamic app configuration loading. + * + * Fetches app configuration from the server via `adapter.getApp(appId)`, + * falling back to `staticConfig` when the server is unavailable. + * Supports cache-based hot-reload via the `refresh()` callback. + * + * @example + * ```tsx + * import { useDynamicApp } from '@object-ui/react'; + * import staticAppConfig from '../config/app.json'; + * + * function App() { + * const { config, isLoading, refresh } = useDynamicApp({ + * appId: 'crm', + * staticConfig: staticAppConfig, + * adapter: dataSource, + * }); + * + * if (isLoading) return ; + * return ; + * } + * ``` + */ +export function useDynamicApp( + options: DynamicAppOptions, +): DynamicAppResult { + const { appId, staticConfig, adapter, enabled = true } = options; + + const [config, setConfig] = useState(staticConfig ?? null); + const [isLoading, setIsLoading] = useState(enabled && !!adapter); + const [error, setError] = useState(null); + const [isServerConfig, setIsServerConfig] = useState(false); + + // Ref to track unmount / stale requests + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + + const loadConfig = useCallback(async () => { + if (!adapter || !enabled) { + setConfig(staticConfig ?? null); + setIsServerConfig(false); + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + + try { + const serverConfig = await adapter.getApp(appId); + + if (!mountedRef.current) return; + + if (serverConfig) { + setConfig(serverConfig as TAppConfig); + setIsServerConfig(true); + } else { + // Server returned null — fall back to static config + setConfig(staticConfig ?? null); + setIsServerConfig(false); + } + } catch (err) { + if (!mountedRef.current) return; + + setError(err instanceof Error ? err : new Error(String(err))); + // Fall back to static config on error + setConfig(staticConfig ?? null); + setIsServerConfig(false); + } finally { + if (mountedRef.current) { + setIsLoading(false); + } + } + }, [adapter, appId, staticConfig, enabled]); + + // Load on mount and when appId changes + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + const refresh = useCallback(async () => { + // Invalidate cache before re-fetching + adapter?.invalidateCache?.(`app:${appId}`); + await loadConfig(); + }, [adapter, appId, loadConfig]); + + return { config, isLoading, error, isServerConfig, refresh }; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 327f6fac5..54068c9fa 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -834,3 +834,23 @@ export type { ObjectQLCapabilities, ObjectUICapabilities, } from '@objectstack/spec'; + +// ============================================================================ +// Widget System - Runtime Widget Registration (Section 1.6) +// ============================================================================ +/** + * Widget manifest and registry types for runtime widget registration, + * plugin auto-discovery, and custom widget registry. + */ +export type { + WidgetManifest, + WidgetSource, + WidgetSourceModule, + WidgetSourceInline, + WidgetSourceRegistry, + WidgetInput, + WidgetCapabilities, + ResolvedWidget, + WidgetRegistryEvent, + WidgetRegistryListener, +} from './widget'; diff --git a/packages/types/src/widget.ts b/packages/types/src/widget.ts new file mode 100644 index 000000000..96cec283c --- /dev/null +++ b/packages/types/src/widget.ts @@ -0,0 +1,187 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @object-ui/types - Widget Manifest & Registry Types + * + * Defines the WidgetManifest interface for runtime widget registration, + * plugin auto-discovery from server metadata, and custom widget registry + * for user-defined components. + * + * @module widget + * @packageDocumentation + */ + +/** + * Widget manifest describing a runtime-loadable widget. + * + * A manifest provides all metadata needed to discover, load, and render + * a widget without requiring an upfront import of its code. + * + * @example + * ```ts + * const manifest: WidgetManifest = { + * name: 'custom-chart', + * version: '1.0.0', + * type: 'chart', + * label: 'Custom Chart Widget', + * description: 'A custom chart powered by D3.', + * category: 'data-visualization', + * icon: 'BarChart', + * source: { type: 'module', url: '/widgets/custom-chart.js' }, + * }; + * ``` + */ +export interface WidgetManifest { + /** Unique widget identifier (e.g., 'custom-chart', 'org.acme.table') */ + name: string; + + /** Semver version string */ + version: string; + + /** Component type key used for schema rendering (e.g., 'chart', 'grid') */ + type: string; + + /** Human-readable label for the widget */ + label: string; + + /** Short description of the widget */ + description?: string; + + /** Category for grouping in the designer palette */ + category?: string; + + /** Icon name (Lucide icon name) or SVG string */ + icon?: string; + + /** Thumbnail image URL for the designer palette */ + thumbnail?: string; + + /** Widget loading source configuration */ + source: WidgetSource; + + /** Required peer dependencies (e.g., { 'react': '^18.0.0' }) */ + peerDependencies?: Record; + + /** Dependencies on other widgets by name */ + dependencies?: string[]; + + /** Default props to apply when the widget is first dropped in the designer */ + defaultProps?: Record; + + /** Input schema for the widget's configurable properties */ + inputs?: WidgetInput[]; + + /** Whether the widget can contain child components */ + isContainer?: boolean; + + /** Widget capabilities */ + capabilities?: WidgetCapabilities; + + /** Additional metadata */ + metadata?: Record; +} + +/** + * Describes how to load the widget's code at runtime. + */ +export type WidgetSource = + | WidgetSourceModule + | WidgetSourceInline + | WidgetSourceRegistry; + +/** Load from an ES module URL */ +export interface WidgetSourceModule { + type: 'module'; + /** URL to the ES module (e.g., '/widgets/chart.js' or 'https://cdn.example.com/widget.mjs') */ + url: string; + /** Named export to use (default: 'default') */ + exportName?: string; +} + +/** The component is provided inline (already loaded) */ +export interface WidgetSourceInline { + type: 'inline'; + /** The React component (already resolved) */ + component: unknown; +} + +/** The component is registered in the global component registry */ +export interface WidgetSourceRegistry { + type: 'registry'; + /** The component type key in the registry */ + registryKey: string; +} + +/** + * Configurable input for a widget. + */ +export interface WidgetInput { + /** Input field name (maps to prop name) */ + name: string; + /** Input field type */ + type: 'string' | 'number' | 'boolean' | 'enum' | 'array' | 'object' | 'color' | 'date' | 'code' | 'file' | 'slot'; + /** Human-readable label */ + label?: string; + /** Default value */ + defaultValue?: unknown; + /** Whether this input is required */ + required?: boolean; + /** Enum options (for type: 'enum') */ + options?: string[] | Array<{ label: string; value: unknown }>; + /** Help text */ + description?: string; + /** Whether this is an advanced setting (hidden by default) */ + advanced?: boolean; +} + +/** + * Widget capabilities flag set. + */ +export interface WidgetCapabilities { + /** Widget supports data binding via dataSource */ + dataBinding?: boolean; + /** Widget supports real-time updates */ + realTime?: boolean; + /** Widget supports export (PDF, CSV, etc.) */ + export?: boolean; + /** Widget supports responsive sizing */ + responsive?: boolean; + /** Widget supports theming */ + themeable?: boolean; + /** Widget supports drag and drop */ + draggable?: boolean; + /** Widget supports resize */ + resizable?: boolean; +} + +/** + * Resolved widget: a manifest with its loaded component. + */ +export interface ResolvedWidget { + /** The original manifest */ + manifest: WidgetManifest; + /** The loaded React component */ + component: unknown; + /** Timestamp when the widget was loaded */ + loadedAt: number; +} + +/** + * Widget registry event types. + */ +export type WidgetRegistryEvent = + | { type: 'widget:registered'; widget: WidgetManifest } + | { type: 'widget:unregistered'; name: string } + | { type: 'widget:loaded'; widget: ResolvedWidget } + | { type: 'widget:error'; name: string; error: Error }; + +/** + * Widget registry event listener. + */ +export type WidgetRegistryListener = (event: WidgetRegistryEvent) => void;