Skip to content

Commit 3fec1a7

Browse files
Merge pull request #1186 from objectstack-ai/copilot/unified-metadata-management
Unified metadata management entry & dynamic routing
2 parents 3a388d6 + 0a0357f commit 3fec1a7

File tree

11 files changed

+992
-20
lines changed

11 files changed

+992
-20
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Unified metadata management architecture** (`@object-ui/console`): New centralized metadata type registry (`config/metadataTypeRegistry.ts`) that defines all manageable metadata categories (app, object, dashboard, page, report) as configuration entries. Registry-driven approach eliminates code duplication — adding a new metadata type requires only a single config entry. Includes `getMetadataTypeConfig()`, `getGenericMetadataTypes()`, and `getHubMetadataTypes()` helpers.
13+
14+
- **Generic MetadataManagerPage** (`@object-ui/console`): New reusable page component (`pages/system/MetadataManagerPage.tsx`) for listing and managing metadata items of any registered type. Driven by the `:metadataType` URL parameter, it fetches items via `MetadataService.getItems()`, supports search filtering, soft-delete with confirm pattern, and displays items in a responsive card grid. Routes: `/system/metadata/:metadataType`.
15+
16+
- **Generic MetadataService methods** (`@object-ui/console`): Extended `MetadataService` with `getItems(category)`, `saveMetadataItem(category, name, data)`, and `deleteMetadataItem(category, name)` methods that work for any metadata type, complementing the existing object/field-specific methods.
17+
18+
- **SystemHubPage registry integration** (`@object-ui/console`): Refactored `SystemHubPage` to auto-generate metadata type cards from the registry instead of hardcoded arrays. New cards (Dashboards, Pages, Reports) appear automatically. Types with custom pages (app, object) link to their existing routes; generic types link to the unified MetadataManagerPage. No breaking changes — all existing hub cards (Users, Orgs, Roles, etc.) remain unchanged.
19+
20+
- **Dynamic routing for metadata types** (`@object-ui/console`): Added `/system/metadata/:metadataType` routes across all three route blocks (SystemRoutes, AppContent minimal, AppContent full) so the generic manager is accessible from both top-level `/system/*` and app-scoped `/apps/:appName/system/*` contexts.
21+
1222
- **Metadata service layer** (`@object-ui/console`): New `MetadataService` class (`services/MetadataService.ts`) that encapsulates object and field CRUD operations against the ObjectStack metadata API (`client.meta.saveItem`). Provides `saveObject()`, `deleteObject()`, and `saveFields()` methods with automatic cache invalidation. Includes static `diffObjects()` and `diffFields()` helpers to detect create/update/delete changes between arrays. Companion `useMetadataService` hook (`hooks/useMetadataService.ts`) provides a memoised service instance from the `useAdapter()` context. 16 new unit tests.
1323

1424
### Fixed

ROADMAP.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,17 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
804804
- [x] `/system/` → SystemHubPage
805805
- [x] `/system/apps` → AppManagementPage
806806
- [x] `/system/permissions` → PermissionManagementPage
807+
- [x] `/system/metadata/:metadataType` → MetadataManagerPage (generic, registry-driven)
808+
809+
**Unified Metadata Management (P1.12.3):**
810+
- [x] Metadata type registry (`config/metadataTypeRegistry.ts`) — centralized config for all metadata types
811+
- [x] Generic `MetadataManagerPage` for listing/managing items of any registered type
812+
- [x] SystemHubPage auto-generates metadata type cards from registry (dashboard, page, report)
813+
- [x] Dynamic `/system/metadata/:metadataType` routes in all route contexts
814+
- [x] Generic `MetadataService` methods: `getItems()`, `saveMetadataItem()`, `deleteMetadataItem()`
815+
- [x] Types with custom pages (`app`, `object`) link to existing dedicated routes
816+
- [x] Legacy routes preserved — no breaking changes
817+
- [x] 40+ new tests (registry, MetadataManagerPage, MetadataService generic, SystemHubPage registry)
807818

808819
**Tests:**
809820
- [x] 11 new tests (SystemHubPage, AppManagementPage, PermissionManagementPage)

apps/console/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage').then(
4848
const SystemHubPage = lazy(() => import('./pages/system/SystemHubPage').then(m => ({ default: m.SystemHubPage })));
4949
const AppManagementPage = lazy(() => import('./pages/system/AppManagementPage').then(m => ({ default: m.AppManagementPage })));
5050
const ObjectManagerPage = lazy(() => import('./pages/system/ObjectManagerPage').then(m => ({ default: m.ObjectManagerPage })));
51+
const MetadataManagerPage = lazy(() => import('./pages/system/MetadataManagerPage').then(m => ({ default: m.MetadataManagerPage })));
5152
const UserManagementPage = lazy(() => import('./pages/system/UserManagementPage').then(m => ({ default: m.UserManagementPage })));
5253
const OrgManagementPage = lazy(() => import('./pages/system/OrgManagementPage').then(m => ({ default: m.OrgManagementPage })));
5354
const RoleManagementPage = lazy(() => import('./pages/system/RoleManagementPage').then(m => ({ default: m.RoleManagementPage })));
@@ -300,6 +301,7 @@ export function AppContent() {
300301
<Route path="system/permissions" element={<PermissionManagementPage />} />
301302
<Route path="system/audit-log" element={<AuditLogPage />} />
302303
<Route path="system/profile" element={<ProfilePage />} />
304+
<Route path="system/metadata/:metadataType" element={<MetadataManagerPage />} />
303305
</Routes>
304306
</Suspense>
305307
);
@@ -394,6 +396,7 @@ export function AppContent() {
394396
<Route path="system/permissions" element={<PermissionManagementPage />} />
395397
<Route path="system/audit-log" element={<AuditLogPage />} />
396398
<Route path="system/profile" element={<ProfilePage />} />
399+
<Route path="system/metadata/:metadataType" element={<MetadataManagerPage />} />
397400
</Routes>
398401
</Suspense>
399402
</ErrorBoundary>
@@ -488,6 +491,7 @@ function SystemRoutes() {
488491
<Route path="permissions" element={<PermissionManagementPage />} />
489492
<Route path="audit-log" element={<AuditLogPage />} />
490493
<Route path="profile" element={<ProfilePage />} />
494+
<Route path="metadata/:metadataType" element={<MetadataManagerPage />} />
491495
</Routes>
492496
</Suspense>
493497
);
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/**
2+
* MetadataManagerPage Tests
3+
*
4+
* Tests for the generic, registry-driven metadata manager page that handles
5+
* listing and deleting metadata items for any registered type.
6+
*/
7+
8+
import { describe, it, expect, vi, beforeEach } from 'vitest';
9+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
10+
import '@testing-library/jest-dom';
11+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
12+
13+
// --- Mock MetadataService ---
14+
const mockGetItems = vi.fn().mockResolvedValue([]);
15+
const mockDeleteMetadataItem = vi.fn().mockResolvedValue(undefined);
16+
17+
vi.mock('../hooks/useMetadataService', () => ({
18+
useMetadataService: () => ({
19+
getItems: mockGetItems,
20+
deleteMetadataItem: mockDeleteMetadataItem,
21+
saveMetadataItem: vi.fn(),
22+
}),
23+
}));
24+
25+
const mockRefresh = vi.fn().mockResolvedValue(undefined);
26+
vi.mock('../context/MetadataProvider', () => ({
27+
useMetadata: () => ({
28+
apps: [],
29+
objects: [],
30+
dashboards: [],
31+
reports: [],
32+
pages: [],
33+
loading: false,
34+
error: null,
35+
refresh: mockRefresh,
36+
}),
37+
}));
38+
39+
vi.mock('sonner', () => ({
40+
toast: { success: vi.fn(), error: vi.fn() },
41+
}));
42+
43+
const mockNavigate = vi.fn();
44+
vi.mock('react-router-dom', async () => {
45+
const actual = await vi.importActual('react-router-dom');
46+
return {
47+
...actual,
48+
useNavigate: () => mockNavigate,
49+
};
50+
});
51+
52+
// Import after mocks
53+
import { MetadataManagerPage } from '../pages/system/MetadataManagerPage';
54+
import { toast } from 'sonner';
55+
56+
function renderWithRoute(metadataType: string) {
57+
return render(
58+
<MemoryRouter initialEntries={[`/system/metadata/${metadataType}`]}>
59+
<Routes>
60+
<Route path="/system/metadata/:metadataType" element={<MetadataManagerPage />} />
61+
</Routes>
62+
</MemoryRouter>
63+
);
64+
}
65+
66+
beforeEach(() => {
67+
vi.clearAllMocks();
68+
mockGetItems.mockResolvedValue([]);
69+
});
70+
71+
describe('MetadataManagerPage', () => {
72+
describe('with known metadata type (dashboard)', () => {
73+
it('should render page heading and description', async () => {
74+
renderWithRoute('dashboard');
75+
await waitFor(() => {
76+
expect(screen.getByText('Dashboards')).toBeInTheDocument();
77+
expect(screen.getByText('Manage dashboard layouts and widgets')).toBeInTheDocument();
78+
});
79+
});
80+
81+
it('should show loading indicator while fetching', () => {
82+
mockGetItems.mockReturnValue(new Promise(() => {})); // never resolves
83+
renderWithRoute('dashboard');
84+
expect(screen.getByTestId('metadata-loading')).toBeInTheDocument();
85+
});
86+
87+
it('should display items from MetadataService', async () => {
88+
mockGetItems.mockResolvedValue([
89+
{ name: 'sales_dashboard', label: 'Sales Dashboard', description: 'Sales KPIs' },
90+
{ name: 'ops_dashboard', label: 'Operations', description: 'Ops overview' },
91+
]);
92+
renderWithRoute('dashboard');
93+
await waitFor(() => {
94+
expect(screen.getByTestId('metadata-item-sales_dashboard')).toBeInTheDocument();
95+
expect(screen.getByTestId('metadata-item-ops_dashboard')).toBeInTheDocument();
96+
});
97+
});
98+
99+
it('should show empty state when no items', async () => {
100+
mockGetItems.mockResolvedValue([]);
101+
renderWithRoute('dashboard');
102+
await waitFor(() => {
103+
expect(screen.getByTestId('metadata-empty')).toBeInTheDocument();
104+
});
105+
});
106+
107+
it('should filter items by search query', async () => {
108+
mockGetItems.mockResolvedValue([
109+
{ name: 'sales_dashboard', label: 'Sales Dashboard' },
110+
{ name: 'ops_dashboard', label: 'Operations' },
111+
]);
112+
renderWithRoute('dashboard');
113+
await waitFor(() => {
114+
expect(screen.getByTestId('metadata-item-sales_dashboard')).toBeInTheDocument();
115+
expect(screen.getByTestId('metadata-item-ops_dashboard')).toBeInTheDocument();
116+
});
117+
fireEvent.change(screen.getByTestId('metadata-search-input'), {
118+
target: { value: 'sales' },
119+
});
120+
await waitFor(() => {
121+
expect(screen.getByTestId('metadata-item-sales_dashboard')).toBeInTheDocument();
122+
expect(screen.queryByTestId('metadata-item-ops_dashboard')).not.toBeInTheDocument();
123+
});
124+
});
125+
126+
it('should filter out soft-deleted items', async () => {
127+
mockGetItems.mockResolvedValue([
128+
{ name: 'active_dash', label: 'Active' },
129+
{ name: 'deleted_dash', label: 'Deleted', _deleted: true },
130+
]);
131+
renderWithRoute('dashboard');
132+
await waitFor(() => {
133+
expect(screen.getByTestId('metadata-item-active_dash')).toBeInTheDocument();
134+
expect(screen.queryByTestId('metadata-item-deleted_dash')).not.toBeInTheDocument();
135+
});
136+
});
137+
138+
it('should delete item on double-click (confirm pattern)', async () => {
139+
mockGetItems.mockResolvedValue([
140+
{ name: 'test_dash', label: 'Test Dashboard' },
141+
]);
142+
renderWithRoute('dashboard');
143+
await waitFor(() => {
144+
expect(screen.getByTestId('metadata-item-test_dash')).toBeInTheDocument();
145+
});
146+
147+
// First click: arm deletion
148+
fireEvent.click(screen.getByTestId('delete-test_dash-btn'));
149+
// Wait for React to process the state update
150+
await waitFor(() => {
151+
expect(screen.getByTestId('delete-test_dash-btn')).toBeInTheDocument();
152+
});
153+
// Second click: confirm
154+
fireEvent.click(screen.getByTestId('delete-test_dash-btn'));
155+
156+
await waitFor(() => {
157+
expect(mockDeleteMetadataItem).toHaveBeenCalledWith('dashboard', 'test_dash');
158+
expect(mockRefresh).toHaveBeenCalled();
159+
expect(toast.success).toHaveBeenCalledWith('Dashboard "test_dash" deleted');
160+
});
161+
});
162+
163+
it('should show count badge', async () => {
164+
mockGetItems.mockResolvedValue([
165+
{ name: 'd1', label: 'D1' },
166+
{ name: 'd2', label: 'D2' },
167+
]);
168+
renderWithRoute('dashboard');
169+
await waitFor(() => {
170+
expect(screen.getByTestId('metadata-count-badge')).toHaveTextContent('2 dashboards');
171+
});
172+
});
173+
174+
it('should navigate back to hub on back button click', async () => {
175+
renderWithRoute('dashboard');
176+
await waitFor(() => {
177+
expect(screen.getByTestId('back-to-hub-btn')).toBeInTheDocument();
178+
});
179+
fireEvent.click(screen.getByTestId('back-to-hub-btn'));
180+
expect(mockNavigate).toHaveBeenCalledWith('/system');
181+
});
182+
});
183+
184+
describe('with known metadata type (page)', () => {
185+
it('should render correct heading for page type', async () => {
186+
renderWithRoute('page');
187+
await waitFor(() => {
188+
expect(screen.getByText('Pages')).toBeInTheDocument();
189+
expect(screen.getByText('Manage custom page definitions')).toBeInTheDocument();
190+
});
191+
});
192+
});
193+
194+
describe('with known metadata type (report)', () => {
195+
it('should render correct heading for report type', async () => {
196+
renderWithRoute('report');
197+
await waitFor(() => {
198+
expect(screen.getByText('Reports')).toBeInTheDocument();
199+
});
200+
});
201+
});
202+
203+
describe('with unknown metadata type', () => {
204+
it('should show unknown type message', () => {
205+
renderWithRoute('nonexistent');
206+
expect(screen.getByText(/Unknown metadata type: nonexistent/)).toBeInTheDocument();
207+
});
208+
});
209+
210+
describe('MetadataService.getItems call', () => {
211+
it('should call getItems with the correct metadata type', async () => {
212+
renderWithRoute('dashboard');
213+
await waitFor(() => {
214+
expect(mockGetItems).toHaveBeenCalledWith('dashboard');
215+
});
216+
});
217+
218+
it('should call getItems with report type', async () => {
219+
renderWithRoute('report');
220+
await waitFor(() => {
221+
expect(mockGetItems).toHaveBeenCalledWith('report');
222+
});
223+
});
224+
});
225+
});

apps/console/src/__tests__/MetadataService.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ function createMockAdapter() {
1818
meta: {
1919
saveItem: vi.fn().mockResolvedValue({}),
2020
getItem: vi.fn().mockResolvedValue({ item: { name: 'account', fields: [] } }),
21+
getItems: vi.fn().mockResolvedValue({ items: [] }),
2122
},
2223
};
2324

@@ -244,4 +245,63 @@ describe('MetadataService', () => {
244245
expect(MetadataService.diffFields(fields, fields)).toBeNull();
245246
});
246247
});
248+
249+
// -------------------------------------------------------------------------
250+
// Tests: Generic metadata operations
251+
// -------------------------------------------------------------------------
252+
253+
describe('getItems', () => {
254+
it('should fetch items for a given category', async () => {
255+
mockClient.meta.getItems = vi.fn().mockResolvedValue({
256+
items: [{ name: 'dash1' }, { name: 'dash2' }],
257+
});
258+
const items = await service.getItems('dashboard');
259+
expect(mockClient.meta.getItems).toHaveBeenCalledWith('dashboard');
260+
expect(items).toEqual([{ name: 'dash1' }, { name: 'dash2' }]);
261+
});
262+
263+
it('should return empty array when response has no items', async () => {
264+
mockClient.meta.getItems = vi.fn().mockResolvedValue({});
265+
const items = await service.getItems('dashboard');
266+
expect(items).toEqual([]);
267+
});
268+
269+
it('should return empty array when response is null', async () => {
270+
mockClient.meta.getItems = vi.fn().mockResolvedValue(null);
271+
const items = await service.getItems('dashboard');
272+
expect(items).toEqual([]);
273+
});
274+
});
275+
276+
describe('saveMetadataItem', () => {
277+
it('should call saveItem with category, name, and data', async () => {
278+
await service.saveMetadataItem('dashboard', 'my_dash', { name: 'my_dash', label: 'My Dash' });
279+
expect(mockClient.meta.saveItem).toHaveBeenCalledWith(
280+
'dashboard',
281+
'my_dash',
282+
{ name: 'my_dash', label: 'My Dash' },
283+
);
284+
});
285+
286+
it('should invalidate cache after save', async () => {
287+
await service.saveMetadataItem('report', 'q1_report', { name: 'q1_report' });
288+
expect(adapter.invalidateCache).toHaveBeenCalledWith('report:q1_report');
289+
});
290+
});
291+
292+
describe('deleteMetadataItem', () => {
293+
it('should soft-delete with enabled=false and _deleted=true', async () => {
294+
await service.deleteMetadataItem('page', 'landing');
295+
expect(mockClient.meta.saveItem).toHaveBeenCalledWith(
296+
'page',
297+
'landing',
298+
{ name: 'landing', enabled: false, _deleted: true },
299+
);
300+
});
301+
302+
it('should invalidate cache after delete', async () => {
303+
await service.deleteMetadataItem('page', 'landing');
304+
expect(adapter.invalidateCache).toHaveBeenCalledWith('page:landing');
305+
});
306+
});
247307
});

0 commit comments

Comments
 (0)