diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5e18476..4171a4919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index bdfe82cf0..2d44871e8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 1f1601ce3..a4ee20c6e 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -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 }))); @@ -300,6 +301,7 @@ export function AppContent() { } /> } /> } /> + } /> ); @@ -394,6 +396,7 @@ export function AppContent() { } /> } /> } /> + } /> @@ -488,6 +491,7 @@ function SystemRoutes() { } /> } /> } /> + } /> ); diff --git a/apps/console/src/__tests__/MetadataManagerPage.test.tsx b/apps/console/src/__tests__/MetadataManagerPage.test.tsx new file mode 100644 index 000000000..7a1dbb6ca --- /dev/null +++ b/apps/console/src/__tests__/MetadataManagerPage.test.tsx @@ -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( + + + } /> + + + ); +} + +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'); + }); + }); + }); +}); diff --git a/apps/console/src/__tests__/MetadataService.test.ts b/apps/console/src/__tests__/MetadataService.test.ts index 73a72c945..b28baab31 100644 --- a/apps/console/src/__tests__/MetadataService.test.ts +++ b/apps/console/src/__tests__/MetadataService.test.ts @@ -18,6 +18,7 @@ function createMockAdapter() { meta: { saveItem: vi.fn().mockResolvedValue({}), getItem: vi.fn().mockResolvedValue({ item: { name: 'account', fields: [] } }), + getItems: vi.fn().mockResolvedValue({ items: [] }), }, }; @@ -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'); + }); + }); }); diff --git a/apps/console/src/__tests__/SystemPages.test.tsx b/apps/console/src/__tests__/SystemPages.test.tsx index adc6a8d36..05994d01c 100644 --- a/apps/console/src/__tests__/SystemPages.test.tsx +++ b/apps/console/src/__tests__/SystemPages.test.tsx @@ -196,6 +196,37 @@ describe('SystemHubPage', () => { }); }); + it('should render metadata type cards from registry', async () => { + mockFind.mockResolvedValue({ data: [] }); + wrap(); + await waitFor(() => { + expect(screen.getByTestId('hub-card-object-manager')).toBeInTheDocument(); + expect(screen.getByTestId('hub-card-dashboards')).toBeInTheDocument(); + expect(screen.getByTestId('hub-card-pages')).toBeInTheDocument(); + expect(screen.getByTestId('hub-card-reports')).toBeInTheDocument(); + }); + }); + + it('should navigate to generic metadata manager for non-custom types', async () => { + mockFind.mockResolvedValue({ data: [] }); + wrap(); + await waitFor(() => { + expect(screen.getByTestId('hub-card-dashboards')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('hub-card-dashboards')); + expect(mockNavigate).toHaveBeenCalledWith('/apps/test-app/system/metadata/dashboard'); + }); + + it('should navigate to custom route for types with custom pages', async () => { + mockFind.mockResolvedValue({ data: [] }); + wrap(); + await waitFor(() => { + expect(screen.getByTestId('hub-card-applications')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('hub-card-applications')); + expect(mockNavigate).toHaveBeenCalledWith('/apps/test-app/system/apps'); + }); + it('should fetch counts from dataSource on mount', async () => { mockFind.mockResolvedValue({ data: [{ id: '1' }] }); wrap(); diff --git a/apps/console/src/__tests__/metadataTypeRegistry.test.ts b/apps/console/src/__tests__/metadataTypeRegistry.test.ts new file mode 100644 index 000000000..ff8e78689 --- /dev/null +++ b/apps/console/src/__tests__/metadataTypeRegistry.test.ts @@ -0,0 +1,97 @@ +/** + * Metadata Type Registry Tests + * + * Tests for the centralized metadata type configuration registry. + */ + +import { describe, it, expect } from 'vitest'; +import { + METADATA_TYPES, + getMetadataTypeConfig, + getGenericMetadataTypes, + getHubMetadataTypes, + type MetadataTypeConfig, +} from '../config/metadataTypeRegistry'; + +describe('metadataTypeRegistry', () => { + describe('METADATA_TYPES', () => { + it('should contain at least the core metadata types', () => { + const types = METADATA_TYPES.map((m) => m.type); + expect(types).toContain('app'); + expect(types).toContain('object'); + expect(types).toContain('dashboard'); + expect(types).toContain('page'); + expect(types).toContain('report'); + }); + + it('should have required fields for every entry', () => { + for (const entry of METADATA_TYPES) { + expect(entry.type).toBeTruthy(); + expect(entry.label).toBeTruthy(); + expect(entry.pluralLabel).toBeTruthy(); + expect(entry.description).toBeTruthy(); + expect(entry.icon).toBeTruthy(); + } + }); + + it('should mark app and object as having custom pages', () => { + const app = METADATA_TYPES.find((m) => m.type === 'app')!; + const obj = METADATA_TYPES.find((m) => m.type === 'object')!; + expect(app.hasCustomPage).toBe(true); + expect(app.customRoute).toBe('/system/apps'); + expect(obj.hasCustomPage).toBe(true); + expect(obj.customRoute).toBe('/system/objects'); + }); + + it('should not mark generic types as having custom pages', () => { + const dashboard = METADATA_TYPES.find((m) => m.type === 'dashboard')!; + const page = METADATA_TYPES.find((m) => m.type === 'page')!; + const report = METADATA_TYPES.find((m) => m.type === 'report')!; + expect(dashboard.hasCustomPage).toBeFalsy(); + expect(page.hasCustomPage).toBeFalsy(); + expect(report.hasCustomPage).toBeFalsy(); + }); + + it('should have unique type strings', () => { + const types = METADATA_TYPES.map((m) => m.type); + expect(new Set(types).size).toBe(types.length); + }); + }); + + describe('getMetadataTypeConfig', () => { + it('should return config for known types', () => { + const config = getMetadataTypeConfig('dashboard'); + expect(config).toBeDefined(); + expect(config!.type).toBe('dashboard'); + expect(config!.label).toBe('Dashboard'); + }); + + it('should return undefined for unknown types', () => { + expect(getMetadataTypeConfig('nonexistent')).toBeUndefined(); + }); + }); + + describe('getGenericMetadataTypes', () => { + it('should exclude types with custom pages', () => { + const generic = getGenericMetadataTypes(); + const types = generic.map((m) => m.type); + expect(types).not.toContain('app'); + expect(types).not.toContain('object'); + }); + + it('should include generic types', () => { + const generic = getGenericMetadataTypes(); + const types = generic.map((m) => m.type); + expect(types).toContain('dashboard'); + expect(types).toContain('page'); + expect(types).toContain('report'); + }); + }); + + describe('getHubMetadataTypes', () => { + it('should return all metadata types', () => { + const hub = getHubMetadataTypes(); + expect(hub.length).toBe(METADATA_TYPES.length); + }); + }); +}); diff --git a/apps/console/src/config/metadataTypeRegistry.ts b/apps/console/src/config/metadataTypeRegistry.ts new file mode 100644 index 000000000..eed4fb9af --- /dev/null +++ b/apps/console/src/config/metadataTypeRegistry.ts @@ -0,0 +1,193 @@ +/** + * Metadata Type Registry + * + * Centralized configuration for all metadata types manageable through the + * unified Metadata Manager. Each entry describes a metadata category + * (e.g. `dashboard`, `page`, `report`) with its label, icon, description, + * and optional column / form definitions used by the generic + * MetadataManagerPage. + * + * To add a new metadata type: + * 1. Add an entry to `METADATA_TYPES` below. + * 2. That's it — routes, hub cards, and CRUD pages are auto-generated. + * + * Types that require a fully custom detail view (e.g. `object`) can specify + * `customRoute` to point at their existing dedicated page and are excluded + * from the generic manager's route generation. + * + * @module config/metadataTypeRegistry + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Column definition used by the generic metadata list view. */ +export interface MetadataColumnDef { + /** Field key in the metadata item. */ + key: string; + /** Human-readable header label. */ + label: string; + /** Optional width hint (e.g. '120px', '1fr'). */ + width?: string; +} + +/** Full configuration for a single metadata type. */ +export interface MetadataTypeConfig { + /** + * The metadata category string sent to `client.meta.getItems(type)` / + * `client.meta.saveItem(type, name, data)`. + */ + type: string; + + /** Human-readable singular label shown in headings and cards. */ + label: string; + + /** Human-readable plural label shown in list headings. */ + pluralLabel: string; + + /** Description shown on the SystemHub card and page subtitle. */ + description: string; + + /** Lucide icon name (lowercase, hyphenated, e.g. `'layout-dashboard'`). */ + icon: string; + + /** + * Column definitions for the generic list view. + * When omitted, the list view shows `name` and `label` columns. + */ + columns?: MetadataColumnDef[]; + + /** + * If `true`, this type already has a dedicated management page and should + * NOT generate a `/system/metadata/:type` route. The hub card will link + * to `customRoute` instead. + */ + hasCustomPage?: boolean; + + /** + * Existing route path (relative to basePath) for types with custom pages. + * Only used when `hasCustomPage` is `true`. + * Example: `'/system/objects'` + */ + customRoute?: string; + + /** + * Data source for the hub card count. + * - `'metadata'` (default): count via `client.meta.getItems(type)` length + * - `'dataSource'`: count via `dataSource.find(countObjectName)` length + */ + countSource?: 'metadata' | 'dataSource'; + + /** + * When `countSource` is `'dataSource'`, the object name to query. + * E.g. `'sys_user'` for the Users card. + */ + countObjectName?: string; + + /** + * Whether this metadata type supports CRUD mutations (create/edit/delete). + * Defaults to `true`. Set to `false` for read-only types (e.g. audit log). + */ + editable?: boolean; +} + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +/** + * The canonical list of all metadata types. + * + * Order determines display order on the SystemHub page. + * Types with `hasCustomPage: true` link to their existing route. + * All other types use the generic MetadataManagerPage. + */ +export const METADATA_TYPES: MetadataTypeConfig[] = [ + // -- Types with existing custom pages -- + { + type: 'app', + label: 'Application', + pluralLabel: 'Applications', + description: 'Manage all configured applications', + icon: 'layout-grid', + hasCustomPage: true, + customRoute: '/system/apps', + countSource: 'metadata', + }, + { + type: 'object', + label: 'Object', + pluralLabel: 'Object Manager', + description: 'Manage object definitions and field configurations', + icon: 'database', + hasCustomPage: true, + customRoute: '/system/objects', + countSource: 'metadata', + }, + + // -- Generic metadata types (managed by MetadataManagerPage) -- + { + type: 'dashboard', + label: 'Dashboard', + pluralLabel: 'Dashboards', + description: 'Manage dashboard layouts and widgets', + icon: 'layout-dashboard', + columns: [ + { key: 'name', label: 'Name' }, + { key: 'label', label: 'Label' }, + { key: 'description', label: 'Description' }, + ], + }, + { + type: 'page', + label: 'Page', + pluralLabel: 'Pages', + description: 'Manage custom page definitions', + icon: 'file-text', + columns: [ + { key: 'name', label: 'Name' }, + { key: 'label', label: 'Label' }, + { key: 'description', label: 'Description' }, + ], + }, + { + type: 'report', + label: 'Report', + pluralLabel: 'Reports', + description: 'Manage report configurations and templates', + icon: 'bar-chart-3', + columns: [ + { key: 'name', label: 'Name' }, + { key: 'label', label: 'Label' }, + { key: 'description', label: 'Description' }, + ], + }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Look up a metadata type config by its `type` string. + * Returns `undefined` if not found. + */ +export function getMetadataTypeConfig(type: string): MetadataTypeConfig | undefined { + return METADATA_TYPES.find((m) => m.type === type); +} + +/** + * Return only the metadata types that use the generic MetadataManagerPage + * (i.e. those without a custom page). + */ +export function getGenericMetadataTypes(): MetadataTypeConfig[] { + return METADATA_TYPES.filter((m) => !m.hasCustomPage); +} + +/** + * Return all metadata types for display on the SystemHub page. + */ +export function getHubMetadataTypes(): MetadataTypeConfig[] { + return METADATA_TYPES; +} diff --git a/apps/console/src/pages/system/MetadataManagerPage.tsx b/apps/console/src/pages/system/MetadataManagerPage.tsx new file mode 100644 index 000000000..1b4e3eecb --- /dev/null +++ b/apps/console/src/pages/system/MetadataManagerPage.tsx @@ -0,0 +1,265 @@ +/** + * MetadataManagerPage + * + * Generic, registry-driven page for listing and managing metadata items of + * any type (dashboard, page, report, etc.). The type is determined by the + * `:metadataType` URL parameter and looked up in the metadata type registry. + * + * Features: + * - Data fetched via `MetadataService.getItems(type)` + * - Create / Edit / Delete with optimistic UI + toast feedback + * - Search filtering by name / label + * - Back-to-hub navigation + * + * @module pages/system/MetadataManagerPage + */ + +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + Button, + Card, + CardContent, + Badge, + Input, +} from '@object-ui/components'; +import { + ArrowLeft, + Plus, + Pencil, + Trash2, + Search, + Loader2, + LayoutDashboard, + FileText, + BarChart3, + Database, + LayoutGrid, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { useMetadataService } from '../../hooks/useMetadataService'; +import { useMetadata } from '../../context/MetadataProvider'; +import { getMetadataTypeConfig, type MetadataTypeConfig } from '../../config/metadataTypeRegistry'; + +// --------------------------------------------------------------------------- +// Icon resolver +// --------------------------------------------------------------------------- + +const ICON_MAP: Record> = { + 'layout-dashboard': LayoutDashboard, + 'file-text': FileText, + 'bar-chart-3': BarChart3, + 'database': Database, + 'layout-grid': LayoutGrid, +}; + +function resolveIcon(iconName: string): React.ComponentType<{ className?: string }> { + return ICON_MAP[iconName] ?? Database; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function MetadataManagerPage() { + const navigate = useNavigate(); + const { appName, metadataType } = useParams<{ appName?: string; metadataType: string }>(); + const basePath = appName ? `/apps/${appName}` : ''; + const metadataService = useMetadataService(); + const { refresh } = useMetadata(); + + // Resolve registry config + const config: MetadataTypeConfig | undefined = metadataType + ? getMetadataTypeConfig(metadataType) + : undefined; + + // State + const [items, setItems] = useState[]>([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [saving, setSaving] = useState(false); + const [deletingName, setDeletingName] = useState(null); + + // Fetch items + const fetchItems = useCallback(async () => { + if (!metadataService || !metadataType) return; + setLoading(true); + try { + const result = await metadataService.getItems(metadataType); + // Filter out soft-deleted items + setItems(result.filter((item) => !item._deleted)); + } catch { + toast.error(`Failed to load ${config?.pluralLabel ?? metadataType}`); + } finally { + setLoading(false); + } + }, [metadataService, metadataType, config?.pluralLabel]); + + useEffect(() => { + fetchItems(); + }, [fetchItems]); + + // Filtered items + const filteredItems = useMemo(() => { + if (!searchQuery.trim()) return items; + const q = searchQuery.toLowerCase(); + return items.filter((item) => { + const name = String(item.name ?? '').toLowerCase(); + const label = String(item.label ?? '').toLowerCase(); + return name.includes(q) || label.includes(q); + }); + }, [items, searchQuery]); + + // Handlers + const handleDelete = useCallback(async (name: string) => { + if (!metadataService || !metadataType) return; + if (deletingName === name) { + // Confirmed delete + setSaving(true); + try { + await metadataService.deleteMetadataItem(metadataType, name); + setItems((prev) => prev.filter((item) => item.name !== name)); + await refresh(); + toast.success(`${config?.label ?? 'Item'} "${name}" deleted`); + } catch { + toast.error(`Failed to delete "${name}"`); + } finally { + setSaving(false); + setDeletingName(null); + } + } else { + setDeletingName(name); + } + }, [metadataService, metadataType, deletingName, config?.label, refresh]); + + // Unknown type guard + if (!config) { + return ( +
+
+

Unknown metadata type: {metadataType}

+ +
+
+ ); + } + + const Icon = resolveIcon(config.icon); + const isEditable = config.editable !== false; + + return ( +
+ {/* Header */} +
+
+ +
+ +
+
+

+ {config.pluralLabel} +

+

+ {config.description} +

+
+
+
+ + {/* Search */} +
+
+ + ) => setSearchQuery(e.target.value)} + className="pl-8" + data-testid="metadata-search-input" + /> +
+ + {filteredItems.length} {config.pluralLabel.toLowerCase()} + +
+ + {/* Loading */} + {loading && ( +
+ + Loading {config.pluralLabel.toLowerCase()}... +
+ )} + + {/* Saving indicator */} + {saving && ( +
+ + Saving... +
+ )} + + {/* Items list */} + {!loading && filteredItems.length === 0 && ( +
+

No {config.pluralLabel.toLowerCase()} found.

+
+ )} + + {!loading && filteredItems.length > 0 && ( +
+ {filteredItems.map((item) => { + const name = String(item.name ?? ''); + const label = String(item.label ?? item.name ?? ''); + const description = String(item.description ?? ''); + return ( + + +
+

{label}

+

{name}

+ {description && ( +

{description}

+ )} +
+ {isEditable && ( +
+ +
+ )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/console/src/pages/system/SystemHubPage.tsx b/apps/console/src/pages/system/SystemHubPage.tsx index 40ea8087c..efd09eea6 100644 --- a/apps/console/src/pages/system/SystemHubPage.tsx +++ b/apps/console/src/pages/system/SystemHubPage.tsx @@ -3,7 +3,8 @@ * * Unified entry point for all system administration functions. * Displays card-based overview linking to Apps, Users, Organizations, - * Roles, Permissions, Audit Log, and Profile management pages. + * Roles, Permissions, Audit Log, Profile management pages, and + * dynamically generated metadata type cards from the registry. */ import { useEffect, useState, useCallback } from 'react'; @@ -26,9 +27,13 @@ import { User, Loader2, Database, + LayoutDashboard, + FileText, + BarChart3, } from 'lucide-react'; import { useAdapter } from '../../context/AdapterProvider'; import { useMetadata } from '../../context/MetadataProvider'; +import { getHubMetadataTypes } from '../../config/metadataTypeRegistry'; interface HubCard { title: string; @@ -39,12 +44,29 @@ interface HubCard { count: number | null; } +// --------------------------------------------------------------------------- +// Icon resolver for registry-driven cards +// --------------------------------------------------------------------------- + +const ICON_MAP: Record> = { + 'layout-grid': LayoutGrid, + 'database': Database, + 'layout-dashboard': LayoutDashboard, + 'file-text': FileText, + 'bar-chart-3': BarChart3, +}; + +function resolveIcon(iconName: string): React.ComponentType<{ className?: string }> { + return ICON_MAP[iconName] ?? Database; +} + export function SystemHubPage() { const navigate = useNavigate(); const { appName } = useParams(); const basePath = appName ? `/apps/${appName}` : ''; const dataSource = useAdapter(); - const { apps, objects: metadataObjects } = useMetadata(); + const metadata = useMetadata(); + const { apps, objects: metadataObjects, dashboards, reports, pages } = metadata; const [counts, setCounts] = useState>({ apps: null, @@ -54,6 +76,10 @@ export function SystemHubPage() { roles: null, permissions: null, auditLogs: null, + // Metadata type counts + dashboard: null, + page: null, + report: null, }); const [loading, setLoading] = useState(true); @@ -77,33 +103,45 @@ export function SystemHubPage() { roles: rolesRes.data?.length ?? 0, permissions: permsRes.data?.length ?? 0, auditLogs: logsRes.data?.length ?? 0, + // Metadata type counts from MetadataProvider + dashboard: dashboards?.length ?? 0, + page: pages?.length ?? 0, + report: reports?.length ?? 0, }); } catch { // Keep nulls on failure } finally { setLoading(false); } - }, [dataSource, apps, metadataObjects]); + }, [dataSource, apps, metadataObjects, dashboards, reports, pages]); useEffect(() => { fetchCounts(); }, [fetchCounts]); - const cards: HubCard[] = [ - { - title: 'Applications', - description: 'Manage all configured applications', - icon: LayoutGrid, - href: `${basePath}/system/apps`, - countLabel: 'apps', - count: counts.apps, - }, - { - title: 'Object Manager', - description: 'Manage object definitions and field configurations', - icon: Database, - href: `${basePath}/system/objects`, - countLabel: 'objects', - count: counts.objects ?? null, - }, + // Build metadata-type cards dynamically from registry + const metadataTypeCards: HubCard[] = getHubMetadataTypes().map((cfg) => { + const href = cfg.hasCustomPage && cfg.customRoute + ? `${basePath}${cfg.customRoute}` + : `${basePath}/system/metadata/${cfg.type}`; + const Icon = resolveIcon(cfg.icon); + + // Resolve count from metadata context or data source counts + let count: number | null = null; + if (cfg.type === 'app') count = counts.apps; + else if (cfg.type === 'object') count = counts.objects; + else count = counts[cfg.type] ?? null; + + return { + title: cfg.pluralLabel, + description: cfg.description, + icon: Icon, + href, + countLabel: cfg.pluralLabel.toLowerCase(), + count, + }; + }); + + // System admin cards (non-metadata, always present) + const systemCards: HubCard[] = [ { title: 'Users', description: 'Manage system users and accounts', @@ -154,6 +192,8 @@ export function SystemHubPage() { }, ]; + const cards: HubCard[] = [...metadataTypeCards, ...systemCards]; + return (
diff --git a/apps/console/src/services/MetadataService.ts b/apps/console/src/services/MetadataService.ts index de979f247..839ce4442 100644 --- a/apps/console/src/services/MetadataService.ts +++ b/apps/console/src/services/MetadataService.ts @@ -115,6 +115,42 @@ function toFieldPayload(field: DesignerFieldDefinition): FieldMetadataPayload { export class MetadataService { constructor(private adapter: ObjectStackAdapter) {} + // ----------------------------------------------------------------------- + // Generic metadata operations (any type) + // ----------------------------------------------------------------------- + + /** + * Fetch all items for a given metadata category. + * Returns the items array from the API response, defaulting to `[]`. + */ + async getItems(category: string): Promise[]> { + const client = this.adapter.getClient(); + const res: unknown = await client.meta.getItems(category); + if (res && typeof res === 'object' && 'items' in res && Array.isArray((res as { items: unknown[] }).items)) { + return (res as { items: Record[] }).items; + } + return []; + } + + /** + * Persist a metadata item (upsert) for any category. + */ + async saveMetadataItem(category: string, name: string, data: Record): Promise { + const client = this.adapter.getClient(); + await client.meta.saveItem(category, name, data); + this.adapter.invalidateCache(`${category}:${name}`); + } + + /** + * Soft-delete a metadata item by persisting it with `enabled: false` and + * `_deleted: true`. Works for any metadata category. + */ + async deleteMetadataItem(category: string, name: string): Promise { + const client = this.adapter.getClient(); + await client.meta.saveItem(category, name, { name, enabled: false, _deleted: true }); + this.adapter.invalidateCache(`${category}:${name}`); + } + // ----------------------------------------------------------------------- // Object operations // -----------------------------------------------------------------------