Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **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.

- **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`.

- **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.

- **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.

- **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.

- **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.

### Fixed
Expand Down
11 changes: 11 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,17 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
- [x] `/system/` → SystemHubPage
- [x] `/system/apps` → AppManagementPage
- [x] `/system/permissions` → PermissionManagementPage
- [x] `/system/metadata/:metadataType` → MetadataManagerPage (generic, registry-driven)

**Unified Metadata Management (P1.12.3):**
- [x] Metadata type registry (`config/metadataTypeRegistry.ts`) — centralized config for all metadata types
- [x] Generic `MetadataManagerPage` for listing/managing items of any registered type
- [x] SystemHubPage auto-generates metadata type cards from registry (dashboard, page, report)
- [x] Dynamic `/system/metadata/:metadataType` routes in all route contexts
- [x] Generic `MetadataService` methods: `getItems()`, `saveMetadataItem()`, `deleteMetadataItem()`
- [x] Types with custom pages (`app`, `object`) link to existing dedicated routes
- [x] Legacy routes preserved — no breaking changes
- [x] 40+ new tests (registry, MetadataManagerPage, MetadataService generic, SystemHubPage registry)

**Tests:**
- [x] 11 new tests (SystemHubPage, AppManagementPage, PermissionManagementPage)
Expand Down
4 changes: 4 additions & 0 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage').then(
const SystemHubPage = lazy(() => import('./pages/system/SystemHubPage').then(m => ({ default: m.SystemHubPage })));
const AppManagementPage = lazy(() => import('./pages/system/AppManagementPage').then(m => ({ default: m.AppManagementPage })));
const ObjectManagerPage = lazy(() => import('./pages/system/ObjectManagerPage').then(m => ({ default: m.ObjectManagerPage })));
const MetadataManagerPage = lazy(() => import('./pages/system/MetadataManagerPage').then(m => ({ default: m.MetadataManagerPage })));
const UserManagementPage = lazy(() => import('./pages/system/UserManagementPage').then(m => ({ default: m.UserManagementPage })));
const OrgManagementPage = lazy(() => import('./pages/system/OrgManagementPage').then(m => ({ default: m.OrgManagementPage })));
const RoleManagementPage = lazy(() => import('./pages/system/RoleManagementPage').then(m => ({ default: m.RoleManagementPage })));
Expand Down Expand Up @@ -300,6 +301,7 @@ export function AppContent() {
<Route path="system/permissions" element={<PermissionManagementPage />} />
<Route path="system/audit-log" element={<AuditLogPage />} />
<Route path="system/profile" element={<ProfilePage />} />
<Route path="system/metadata/:metadataType" element={<MetadataManagerPage />} />
</Routes>
</Suspense>
);
Expand Down Expand Up @@ -394,6 +396,7 @@ export function AppContent() {
<Route path="system/permissions" element={<PermissionManagementPage />} />
<Route path="system/audit-log" element={<AuditLogPage />} />
<Route path="system/profile" element={<ProfilePage />} />
<Route path="system/metadata/:metadataType" element={<MetadataManagerPage />} />
</Routes>
</Suspense>
</ErrorBoundary>
Expand Down Expand Up @@ -488,6 +491,7 @@ function SystemRoutes() {
<Route path="permissions" element={<PermissionManagementPage />} />
<Route path="audit-log" element={<AuditLogPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="metadata/:metadataType" element={<MetadataManagerPage />} />
</Routes>
</Suspense>
);
Expand Down
225 changes: 225 additions & 0 deletions apps/console/src/__tests__/MetadataManagerPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/**
* MetadataManagerPage Tests
*
* Tests for the generic, registry-driven metadata manager page that handles
* listing and deleting metadata items for any registered type.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter, Routes, Route } from 'react-router-dom';

// --- Mock MetadataService ---
const mockGetItems = vi.fn().mockResolvedValue([]);
const mockDeleteMetadataItem = vi.fn().mockResolvedValue(undefined);

vi.mock('../hooks/useMetadataService', () => ({
useMetadataService: () => ({
getItems: mockGetItems,
deleteMetadataItem: mockDeleteMetadataItem,
saveMetadataItem: vi.fn(),
}),
}));

const mockRefresh = vi.fn().mockResolvedValue(undefined);
vi.mock('../context/MetadataProvider', () => ({
useMetadata: () => ({
apps: [],
objects: [],
dashboards: [],
reports: [],
pages: [],
loading: false,
error: null,
refresh: mockRefresh,
}),
}));

vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));

const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});

// Import after mocks
import { MetadataManagerPage } from '../pages/system/MetadataManagerPage';
import { toast } from 'sonner';

function renderWithRoute(metadataType: string) {
return render(
<MemoryRouter initialEntries={[`/system/metadata/${metadataType}`]}>
<Routes>
<Route path="/system/metadata/:metadataType" element={<MetadataManagerPage />} />
</Routes>
</MemoryRouter>
);
}

beforeEach(() => {
vi.clearAllMocks();
mockGetItems.mockResolvedValue([]);
});

describe('MetadataManagerPage', () => {
describe('with known metadata type (dashboard)', () => {
it('should render page heading and description', async () => {
renderWithRoute('dashboard');
await waitFor(() => {
expect(screen.getByText('Dashboards')).toBeInTheDocument();
expect(screen.getByText('Manage dashboard layouts and widgets')).toBeInTheDocument();
});
});

it('should show loading indicator while fetching', () => {
mockGetItems.mockReturnValue(new Promise(() => {})); // never resolves
renderWithRoute('dashboard');
expect(screen.getByTestId('metadata-loading')).toBeInTheDocument();
});

it('should display items from MetadataService', async () => {
mockGetItems.mockResolvedValue([
{ name: 'sales_dashboard', label: 'Sales Dashboard', description: 'Sales KPIs' },
{ name: 'ops_dashboard', label: 'Operations', description: 'Ops overview' },
]);
renderWithRoute('dashboard');
await waitFor(() => {
expect(screen.getByTestId('metadata-item-sales_dashboard')).toBeInTheDocument();
expect(screen.getByTestId('metadata-item-ops_dashboard')).toBeInTheDocument();
});
});

it('should show empty state when no items', async () => {
mockGetItems.mockResolvedValue([]);
renderWithRoute('dashboard');
await waitFor(() => {
expect(screen.getByTestId('metadata-empty')).toBeInTheDocument();
});
});

it('should filter items by search query', async () => {
mockGetItems.mockResolvedValue([
{ name: 'sales_dashboard', label: 'Sales Dashboard' },
{ name: 'ops_dashboard', label: 'Operations' },
]);
renderWithRoute('dashboard');
await waitFor(() => {
expect(screen.getByTestId('metadata-item-sales_dashboard')).toBeInTheDocument();
expect(screen.getByTestId('metadata-item-ops_dashboard')).toBeInTheDocument();
});
fireEvent.change(screen.getByTestId('metadata-search-input'), {
target: { value: 'sales' },
});
await waitFor(() => {
expect(screen.getByTestId('metadata-item-sales_dashboard')).toBeInTheDocument();
expect(screen.queryByTestId('metadata-item-ops_dashboard')).not.toBeInTheDocument();
});
});

it('should filter out soft-deleted items', async () => {
mockGetItems.mockResolvedValue([
{ name: 'active_dash', label: 'Active' },
{ name: 'deleted_dash', label: 'Deleted', _deleted: true },
]);
renderWithRoute('dashboard');
await waitFor(() => {
expect(screen.getByTestId('metadata-item-active_dash')).toBeInTheDocument();
expect(screen.queryByTestId('metadata-item-deleted_dash')).not.toBeInTheDocument();
});
});

it('should delete item on double-click (confirm pattern)', async () => {
mockGetItems.mockResolvedValue([
{ name: 'test_dash', label: 'Test Dashboard' },
]);
renderWithRoute('dashboard');
await waitFor(() => {
expect(screen.getByTestId('metadata-item-test_dash')).toBeInTheDocument();
});

// First click: arm deletion
fireEvent.click(screen.getByTestId('delete-test_dash-btn'));
// Wait for React to process the state update
await waitFor(() => {
expect(screen.getByTestId('delete-test_dash-btn')).toBeInTheDocument();
});
// Second click: confirm
fireEvent.click(screen.getByTestId('delete-test_dash-btn'));

await waitFor(() => {
expect(mockDeleteMetadataItem).toHaveBeenCalledWith('dashboard', 'test_dash');
expect(mockRefresh).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith('Dashboard "test_dash" deleted');
});
});

it('should show count badge', async () => {
mockGetItems.mockResolvedValue([
{ name: 'd1', label: 'D1' },
{ name: 'd2', label: 'D2' },
]);
renderWithRoute('dashboard');
await waitFor(() => {
expect(screen.getByTestId('metadata-count-badge')).toHaveTextContent('2 dashboards');
});
});

it('should navigate back to hub on back button click', async () => {
renderWithRoute('dashboard');
await waitFor(() => {
expect(screen.getByTestId('back-to-hub-btn')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('back-to-hub-btn'));
expect(mockNavigate).toHaveBeenCalledWith('/system');
});
});

describe('with known metadata type (page)', () => {
it('should render correct heading for page type', async () => {
renderWithRoute('page');
await waitFor(() => {
expect(screen.getByText('Pages')).toBeInTheDocument();
expect(screen.getByText('Manage custom page definitions')).toBeInTheDocument();
});
});
});

describe('with known metadata type (report)', () => {
it('should render correct heading for report type', async () => {
renderWithRoute('report');
await waitFor(() => {
expect(screen.getByText('Reports')).toBeInTheDocument();
});
});
});

describe('with unknown metadata type', () => {
it('should show unknown type message', () => {
renderWithRoute('nonexistent');
expect(screen.getByText(/Unknown metadata type: nonexistent/)).toBeInTheDocument();
});
});

describe('MetadataService.getItems call', () => {
it('should call getItems with the correct metadata type', async () => {
renderWithRoute('dashboard');
await waitFor(() => {
expect(mockGetItems).toHaveBeenCalledWith('dashboard');
});
});

it('should call getItems with report type', async () => {
renderWithRoute('report');
await waitFor(() => {
expect(mockGetItems).toHaveBeenCalledWith('report');
});
});
});
});
60 changes: 60 additions & 0 deletions apps/console/src/__tests__/MetadataService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function createMockAdapter() {
meta: {
saveItem: vi.fn().mockResolvedValue({}),
getItem: vi.fn().mockResolvedValue({ item: { name: 'account', fields: [] } }),
getItems: vi.fn().mockResolvedValue({ items: [] }),
},
};

Expand Down Expand Up @@ -244,4 +245,63 @@ describe('MetadataService', () => {
expect(MetadataService.diffFields(fields, fields)).toBeNull();
});
});

// -------------------------------------------------------------------------
// Tests: Generic metadata operations
// -------------------------------------------------------------------------

describe('getItems', () => {
it('should fetch items for a given category', async () => {
mockClient.meta.getItems = vi.fn().mockResolvedValue({
items: [{ name: 'dash1' }, { name: 'dash2' }],
});
const items = await service.getItems('dashboard');
expect(mockClient.meta.getItems).toHaveBeenCalledWith('dashboard');
expect(items).toEqual([{ name: 'dash1' }, { name: 'dash2' }]);
});

it('should return empty array when response has no items', async () => {
mockClient.meta.getItems = vi.fn().mockResolvedValue({});
const items = await service.getItems('dashboard');
expect(items).toEqual([]);
});

it('should return empty array when response is null', async () => {
mockClient.meta.getItems = vi.fn().mockResolvedValue(null);
const items = await service.getItems('dashboard');
expect(items).toEqual([]);
});
});

describe('saveMetadataItem', () => {
it('should call saveItem with category, name, and data', async () => {
await service.saveMetadataItem('dashboard', 'my_dash', { name: 'my_dash', label: 'My Dash' });
expect(mockClient.meta.saveItem).toHaveBeenCalledWith(
'dashboard',
'my_dash',
{ name: 'my_dash', label: 'My Dash' },
);
});

it('should invalidate cache after save', async () => {
await service.saveMetadataItem('report', 'q1_report', { name: 'q1_report' });
expect(adapter.invalidateCache).toHaveBeenCalledWith('report:q1_report');
});
});

describe('deleteMetadataItem', () => {
it('should soft-delete with enabled=false and _deleted=true', async () => {
await service.deleteMetadataItem('page', 'landing');
expect(mockClient.meta.saveItem).toHaveBeenCalledWith(
'page',
'landing',
{ name: 'landing', enabled: false, _deleted: true },
);
});

it('should invalidate cache after delete', async () => {
await service.deleteMetadataItem('page', 'landing');
expect(adapter.invalidateCache).toHaveBeenCalledWith('page:landing');
});
});
});
Loading