From 96498597a97655289b7c90e782d2cffc3ef0e8a8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 7 Apr 2026 03:36:13 +0000
Subject: [PATCH 1/5] Initial plan
From f91c312488b4cb51918264def2b3941ad31e3c41 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 7 Apr 2026 03:45:34 +0000
Subject: [PATCH 2/5] feat: add metadata CRUD, form dialog, detail page, and
registry extensions
- Add MetadataFormDialog component for create/edit with registry-driven form fields
- Add Create button and Edit button to MetadataManagerPage
- Add MetadataDetailPage at /system/metadata/:metadataType/:itemName
- Extend MetadataTypeConfig with formFields, detailComponent
- Add formFields to dashboard, page, report registry entries
- Add DEFAULT_FORM_FIELDS constant for fallback
- Add routes for detail page in all three route blocks
- Add 30 new tests (MetadataFormDialog, MetadataDetailPage, create/edit flows)
- Update CHANGELOG.md
Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/c72f47f2-b9dd-4364-b613-535fb3b9d405
Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com>
---
CHANGELOG.md | 8 +
apps/console/src/App.tsx | 4 +
.../src/__tests__/MetadataDetailPage.test.tsx | 154 +++++++++++
.../src/__tests__/MetadataFormDialog.test.tsx | 186 ++++++++++++++
.../__tests__/MetadataManagerPage.test.tsx | 108 +++++++-
.../src/components/MetadataFormDialog.tsx | 198 ++++++++++++++
.../src/config/metadataTypeRegistry.ts | 61 +++++
.../src/pages/system/MetadataDetailPage.tsx | 241 ++++++++++++++++++
.../src/pages/system/MetadataManagerPage.tsx | 91 ++++++-
9 files changed, 1048 insertions(+), 3 deletions(-)
create mode 100644 apps/console/src/__tests__/MetadataDetailPage.test.tsx
create mode 100644 apps/console/src/__tests__/MetadataFormDialog.test.tsx
create mode 100644 apps/console/src/components/MetadataFormDialog.tsx
create mode 100644 apps/console/src/pages/system/MetadataDetailPage.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4171a4919..8b27ce185 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **Metadata Create & Edit via MetadataFormDialog** (`@object-ui/console`): New generic `MetadataFormDialog` component (`components/MetadataFormDialog.tsx`) provides a registry-driven create/edit dialog for any metadata type. Form fields are determined by the `formFields` configuration in the metadata type registry, with fallback defaults (`name`, `label`, `description`). Supports required validation, `disabledOnEdit` for immutable keys (e.g. `name`), textarea and select field types, and loading state during submission.
+
+- **MetadataManagerPage CRUD enhancements** (`@object-ui/console`): Extended the generic MetadataManagerPage with "New" button in the header (opens create dialog), per-item edit buttons (opens pre-filled edit dialog), and click-to-navigate to the detail page. All mutations use `MetadataService.saveMetadataItem()` with toast feedback, loading state, and automatic list refresh.
+
+- **MetadataDetailPage** (`@object-ui/console`): New detail page component (`pages/system/MetadataDetailPage.tsx`) for viewing a single metadata item at `/system/metadata/:metadataType/:itemName`. Displays item fields from the registry's `formFields` config, supports editing via MetadataFormDialog, and allows custom detail renderers via the registry's `detailComponent` property.
+
+- **Registry extensions** (`@object-ui/console`): Extended `MetadataTypeConfig` interface with `formFields` (standardized create/edit form structure), `detailComponent` (custom detail renderers), and `MetadataFormFieldDef` type. Added `formFields` entries for dashboard, page, and report types. Added `DEFAULT_FORM_FIELDS` constant for types without explicit form configuration.
+
- **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`.
diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx
index a4ee20c6e..15e4249b1 100644
--- a/apps/console/src/App.tsx
+++ b/apps/console/src/App.tsx
@@ -49,6 +49,7 @@ const SystemHubPage = lazy(() => import('./pages/system/SystemHubPage').then(m =
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 MetadataDetailPage = lazy(() => import('./pages/system/MetadataDetailPage').then(m => ({ default: m.MetadataDetailPage })));
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 })));
@@ -302,6 +303,7 @@ export function AppContent() {
} />
} />
} />
+ } />
);
@@ -397,6 +399,7 @@ export function AppContent() {
} />
} />
} />
+ } />
@@ -492,6 +495,7 @@ function SystemRoutes() {
} />
} />
} />
+ } />
);
diff --git a/apps/console/src/__tests__/MetadataDetailPage.test.tsx b/apps/console/src/__tests__/MetadataDetailPage.test.tsx
new file mode 100644
index 000000000..271ec06d4
--- /dev/null
+++ b/apps/console/src/__tests__/MetadataDetailPage.test.tsx
@@ -0,0 +1,154 @@
+/**
+ * MetadataDetailPage Tests
+ *
+ * Tests for the generic, registry-driven metadata detail page that shows
+ * a single metadata item and supports editing via the MetadataFormDialog.
+ */
+
+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 mockSaveMetadataItem = vi.fn().mockResolvedValue(undefined);
+
+vi.mock('../hooks/useMetadataService', () => ({
+ useMetadataService: () => ({
+ getItems: mockGetItems,
+ saveMetadataItem: mockSaveMetadataItem,
+ }),
+}));
+
+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 { MetadataDetailPage } from '../pages/system/MetadataDetailPage';
+import { toast } from 'sonner';
+
+function renderWithRoute(metadataType: string, itemName: string) {
+ return render(
+
+
+ }
+ />
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetItems.mockResolvedValue([]);
+});
+
+describe('MetadataDetailPage', () => {
+ describe('with known type and existing item', () => {
+ beforeEach(() => {
+ mockGetItems.mockResolvedValue([
+ { name: 'sales_dash', label: 'Sales Dashboard', description: 'Sales KPIs' },
+ { name: 'ops_dash', label: 'Operations', description: 'Ops overview' },
+ ]);
+ });
+
+ it('should render item details', async () => {
+ renderWithRoute('dashboard', 'sales_dash');
+ await waitFor(() => {
+ expect(screen.getByTestId('detail-card')).toBeInTheDocument();
+ });
+ // "Sales Dashboard" appears in both heading and detail card
+ expect(screen.getAllByText('Sales Dashboard').length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('should show edit button', async () => {
+ renderWithRoute('dashboard', 'sales_dash');
+ await waitFor(() => {
+ expect(screen.getByTestId('detail-edit-btn')).toBeInTheDocument();
+ });
+ });
+
+ it('should show back button that navigates to list', async () => {
+ renderWithRoute('dashboard', 'sales_dash');
+ await waitFor(() => {
+ expect(screen.getByTestId('back-to-list-btn')).toBeInTheDocument();
+ });
+ fireEvent.click(screen.getByTestId('back-to-list-btn'));
+ expect(mockNavigate).toHaveBeenCalledWith('/system/metadata/dashboard');
+ });
+
+ it('should open edit dialog when edit button clicked', async () => {
+ renderWithRoute('dashboard', 'sales_dash');
+ await waitFor(() => {
+ expect(screen.getByTestId('detail-edit-btn')).toBeInTheDocument();
+ });
+ fireEvent.click(screen.getByTestId('detail-edit-btn'));
+ await waitFor(() => {
+ expect(screen.getByTestId('metadata-form-dialog')).toBeInTheDocument();
+ });
+ });
+
+ it('should display field values from the item', async () => {
+ renderWithRoute('dashboard', 'sales_dash');
+ await waitFor(() => {
+ expect(screen.getByTestId('detail-card')).toBeInTheDocument();
+ });
+ // "sales_dash" appears in both the heading subtitle and the detail card
+ expect(screen.getAllByText('sales_dash').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('Sales Dashboard').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('Sales KPIs')).toBeInTheDocument();
+ });
+ });
+
+ describe('with item not found', () => {
+ it('should show not found message', async () => {
+ mockGetItems.mockResolvedValue([]);
+ renderWithRoute('dashboard', 'nonexistent');
+ await waitFor(() => {
+ expect(screen.getByTestId('detail-not-found')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('with unknown metadata type', () => {
+ it('should show unknown type message', () => {
+ renderWithRoute('nonexistent_type', 'some_item');
+ expect(screen.getByText(/Unknown metadata type: nonexistent_type/)).toBeInTheDocument();
+ });
+ });
+
+ describe('loading state', () => {
+ it('should show loading indicator while fetching', () => {
+ mockGetItems.mockReturnValue(new Promise(() => {})); // never resolves
+ renderWithRoute('dashboard', 'sales_dash');
+ expect(screen.getByTestId('detail-loading')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/console/src/__tests__/MetadataFormDialog.test.tsx b/apps/console/src/__tests__/MetadataFormDialog.test.tsx
new file mode 100644
index 000000000..263e9767c
--- /dev/null
+++ b/apps/console/src/__tests__/MetadataFormDialog.test.tsx
@@ -0,0 +1,186 @@
+/**
+ * MetadataFormDialog Tests
+ *
+ * Tests for the generic create/edit dialog driven by the metadata type
+ * registry's `formFields` configuration.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { MetadataFormDialog } from '../components/MetadataFormDialog';
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('MetadataFormDialog', () => {
+ const defaultProps = {
+ open: true,
+ onOpenChange: vi.fn(),
+ mode: 'create' as const,
+ typeLabel: 'Dashboard',
+ onSubmit: vi.fn().mockResolvedValue(undefined),
+ };
+
+ describe('create mode', () => {
+ it('should render dialog with create title', () => {
+ render();
+ expect(screen.getByText('New Dashboard')).toBeInTheDocument();
+ });
+
+ it('should render default form fields when no formFields provided', () => {
+ render();
+ expect(screen.getByTestId('metadata-field-name')).toBeInTheDocument();
+ expect(screen.getByTestId('metadata-field-label')).toBeInTheDocument();
+ expect(screen.getByTestId('metadata-field-description')).toBeInTheDocument();
+ });
+
+ it('should render custom form fields from formFields prop', () => {
+ const formFields = [
+ { key: 'name', label: 'Name', required: true },
+ { key: 'title', label: 'Title', required: false },
+ ];
+ render();
+ expect(screen.getByTestId('metadata-field-name')).toBeInTheDocument();
+ expect(screen.getByTestId('metadata-field-title')).toBeInTheDocument();
+ expect(screen.queryByTestId('metadata-field-description')).not.toBeInTheDocument();
+ });
+
+ it('should show Create button text', () => {
+ render();
+ expect(screen.getByTestId('metadata-form-submit-btn')).toHaveTextContent('Create');
+ });
+
+ it('should disable submit when required fields are empty', () => {
+ render();
+ expect(screen.getByTestId('metadata-form-submit-btn')).toBeDisabled();
+ });
+
+ it('should enable submit when required fields are filled', () => {
+ render();
+ fireEvent.change(screen.getByTestId('metadata-field-name'), {
+ target: { value: 'my_dash' },
+ });
+ fireEvent.change(screen.getByTestId('metadata-field-label'), {
+ target: { value: 'My Dash' },
+ });
+ expect(screen.getByTestId('metadata-form-submit-btn')).not.toBeDisabled();
+ });
+
+ it('should call onSubmit with form values when submitted', async () => {
+ const mockSubmit = vi.fn().mockResolvedValue(undefined);
+ render();
+
+ fireEvent.change(screen.getByTestId('metadata-field-name'), {
+ target: { value: 'my_dash' },
+ });
+ fireEvent.change(screen.getByTestId('metadata-field-label'), {
+ target: { value: 'My Dashboard' },
+ });
+ fireEvent.click(screen.getByTestId('metadata-form-submit-btn'));
+
+ await waitFor(() => {
+ expect(mockSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'my_dash',
+ label: 'My Dashboard',
+ }),
+ );
+ });
+ });
+
+ it('should call onOpenChange(false) after successful submit', async () => {
+ const mockOpenChange = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.change(screen.getByTestId('metadata-field-name'), {
+ target: { value: 'test' },
+ });
+ fireEvent.change(screen.getByTestId('metadata-field-label'), {
+ target: { value: 'Test' },
+ });
+ fireEvent.click(screen.getByTestId('metadata-form-submit-btn'));
+
+ await waitFor(() => {
+ expect(mockOpenChange).toHaveBeenCalledWith(false);
+ });
+ });
+
+ it('should close dialog when Cancel is clicked', () => {
+ const mockOpenChange = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByTestId('metadata-form-cancel-btn'));
+ expect(mockOpenChange).toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('edit mode', () => {
+ const editProps = {
+ ...defaultProps,
+ mode: 'edit' as const,
+ initialValues: { name: 'existing_dash', label: 'Existing Dashboard', description: 'Old desc' },
+ };
+
+ it('should render dialog with edit title', () => {
+ render();
+ expect(screen.getByText('Edit Dashboard')).toBeInTheDocument();
+ });
+
+ it('should show Save button text', () => {
+ render();
+ expect(screen.getByTestId('metadata-form-submit-btn')).toHaveTextContent('Save');
+ });
+
+ it('should pre-fill form fields with initial values', () => {
+ render();
+ expect(screen.getByTestId('metadata-field-name')).toHaveValue('existing_dash');
+ expect(screen.getByTestId('metadata-field-label')).toHaveValue('Existing Dashboard');
+ expect(screen.getByTestId('metadata-field-description')).toHaveValue('Old desc');
+ });
+
+ it('should disable fields with disabledOnEdit in edit mode', () => {
+ const formFields = [
+ { key: 'name', label: 'Name', required: true, disabledOnEdit: true },
+ { key: 'label', label: 'Label', required: true },
+ ];
+ render(
+ ,
+ );
+ expect(screen.getByTestId('metadata-field-name')).toBeDisabled();
+ expect(screen.getByTestId('metadata-field-label')).not.toBeDisabled();
+ });
+ });
+
+ describe('textarea fields', () => {
+ it('should render textarea for fields with type textarea', () => {
+ const formFields = [
+ { key: 'name', label: 'Name', required: true },
+ { key: 'description', label: 'Description', type: 'textarea' as const },
+ ];
+ render();
+ const desc = screen.getByTestId('metadata-field-description');
+ expect(desc.tagName).toBe('TEXTAREA');
+ });
+ });
+
+ describe('when dialog is closed', () => {
+ it('should not render dialog content when open is false', () => {
+ render();
+ expect(screen.queryByTestId('metadata-form-dialog')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/console/src/__tests__/MetadataManagerPage.test.tsx b/apps/console/src/__tests__/MetadataManagerPage.test.tsx
index 7a1dbb6ca..f89206663 100644
--- a/apps/console/src/__tests__/MetadataManagerPage.test.tsx
+++ b/apps/console/src/__tests__/MetadataManagerPage.test.tsx
@@ -13,12 +13,13 @@ import { MemoryRouter, Routes, Route } from 'react-router-dom';
// --- Mock MetadataService ---
const mockGetItems = vi.fn().mockResolvedValue([]);
const mockDeleteMetadataItem = vi.fn().mockResolvedValue(undefined);
+const mockSaveMetadataItem = vi.fn().mockResolvedValue(undefined);
vi.mock('../hooks/useMetadataService', () => ({
useMetadataService: () => ({
getItems: mockGetItems,
deleteMetadataItem: mockDeleteMetadataItem,
- saveMetadataItem: vi.fn(),
+ saveMetadataItem: mockSaveMetadataItem,
}),
}));
@@ -222,4 +223,109 @@ describe('MetadataManagerPage', () => {
});
});
});
+
+ describe('create functionality', () => {
+ it('should show create button for editable types', async () => {
+ renderWithRoute('dashboard');
+ await waitFor(() => {
+ expect(screen.getByTestId('create-metadata-btn')).toBeInTheDocument();
+ });
+ });
+
+ it('should open form dialog when create button is clicked', async () => {
+ renderWithRoute('dashboard');
+ await waitFor(() => {
+ expect(screen.getByTestId('create-metadata-btn')).toBeInTheDocument();
+ });
+ fireEvent.click(screen.getByTestId('create-metadata-btn'));
+ await waitFor(() => {
+ expect(screen.getByTestId('metadata-form-dialog')).toBeInTheDocument();
+ });
+ });
+
+ it('should call saveMetadataItem on create form submit', async () => {
+ renderWithRoute('dashboard');
+ await waitFor(() => {
+ expect(screen.getByTestId('create-metadata-btn')).toBeInTheDocument();
+ });
+ fireEvent.click(screen.getByTestId('create-metadata-btn'));
+ await waitFor(() => {
+ expect(screen.getByTestId('metadata-form-dialog')).toBeInTheDocument();
+ });
+
+ fireEvent.change(screen.getByTestId('metadata-field-name'), {
+ target: { value: 'new_dash' },
+ });
+ fireEvent.change(screen.getByTestId('metadata-field-label'), {
+ target: { value: 'New Dash' },
+ });
+ fireEvent.click(screen.getByTestId('metadata-form-submit-btn'));
+
+ await waitFor(() => {
+ expect(mockSaveMetadataItem).toHaveBeenCalledWith(
+ 'dashboard',
+ 'new_dash',
+ expect.objectContaining({ name: 'new_dash', label: 'New Dash' }),
+ );
+ expect(toast.success).toHaveBeenCalledWith('Dashboard "new_dash" created');
+ });
+ });
+ });
+
+ describe('edit functionality', () => {
+ it('should show edit button for each item', async () => {
+ mockGetItems.mockResolvedValue([
+ { name: 'test_dash', label: 'Test Dashboard' },
+ ]);
+ renderWithRoute('dashboard');
+ await waitFor(() => {
+ expect(screen.getByTestId('edit-test_dash-btn')).toBeInTheDocument();
+ });
+ });
+
+ it('should open edit form dialog when edit button is clicked', async () => {
+ mockGetItems.mockResolvedValue([
+ { name: 'test_dash', label: 'Test Dashboard', description: 'Desc' },
+ ]);
+ renderWithRoute('dashboard');
+ await waitFor(() => {
+ expect(screen.getByTestId('edit-test_dash-btn')).toBeInTheDocument();
+ });
+ fireEvent.click(screen.getByTestId('edit-test_dash-btn'));
+ await waitFor(() => {
+ expect(screen.getByTestId('metadata-form-dialog')).toBeInTheDocument();
+ expect(screen.getByText('Edit Dashboard')).toBeInTheDocument();
+ });
+ });
+
+ it('should pre-fill edit form with item values', async () => {
+ mockGetItems.mockResolvedValue([
+ { name: 'test_dash', label: 'Test Dashboard', description: 'Some desc' },
+ ]);
+ renderWithRoute('dashboard');
+ await waitFor(() => {
+ expect(screen.getByTestId('edit-test_dash-btn')).toBeInTheDocument();
+ });
+ fireEvent.click(screen.getByTestId('edit-test_dash-btn'));
+ await waitFor(() => {
+ expect(screen.getByTestId('metadata-field-name')).toHaveValue('test_dash');
+ expect(screen.getByTestId('metadata-field-label')).toHaveValue('Test Dashboard');
+ expect(screen.getByTestId('metadata-field-description')).toHaveValue('Some desc');
+ });
+ });
+ });
+
+ describe('item navigation to detail', () => {
+ it('should navigate to detail page when item card is clicked', async () => {
+ mockGetItems.mockResolvedValue([
+ { name: 'sales_dash', label: 'Sales Dashboard' },
+ ]);
+ renderWithRoute('dashboard');
+ await waitFor(() => {
+ expect(screen.getByTestId('metadata-item-sales_dash')).toBeInTheDocument();
+ });
+ fireEvent.click(screen.getByTestId('metadata-item-sales_dash'));
+ expect(mockNavigate).toHaveBeenCalledWith('/system/metadata/dashboard/sales_dash');
+ });
+ });
});
diff --git a/apps/console/src/components/MetadataFormDialog.tsx b/apps/console/src/components/MetadataFormDialog.tsx
new file mode 100644
index 000000000..293267a64
--- /dev/null
+++ b/apps/console/src/components/MetadataFormDialog.tsx
@@ -0,0 +1,198 @@
+/**
+ * MetadataFormDialog
+ *
+ * Generic, registry-driven dialog for creating and editing metadata items.
+ * Form fields are determined by the `formFields` configuration in the metadata
+ * type registry, falling back to the default `name`, `label`, `description`
+ * fields when not specified.
+ *
+ * @module components/MetadataFormDialog
+ */
+
+import { useState, useCallback, useEffect } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+ Button,
+ Input,
+ Label,
+ Textarea,
+} from '@object-ui/components';
+import { Loader2 } from 'lucide-react';
+import {
+ DEFAULT_FORM_FIELDS,
+ type MetadataFormFieldDef,
+} from '../config/metadataTypeRegistry';
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export interface MetadataFormDialogProps {
+ /** Whether the dialog is open. */
+ open: boolean;
+ /** Callback when the dialog open state changes. */
+ onOpenChange: (open: boolean) => void;
+ /** Dialog mode — create or edit. */
+ mode: 'create' | 'edit';
+ /** Human-readable type label (e.g. `'Dashboard'`). */
+ typeLabel: string;
+ /** Form field definitions from the registry (or defaults). */
+ formFields?: MetadataFormFieldDef[];
+ /** Initial values when editing an existing item. */
+ initialValues?: Record;
+ /** Called when the user submits the form. Returns a promise. */
+ onSubmit: (values: Record) => Promise;
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export function MetadataFormDialog({
+ open,
+ onOpenChange,
+ mode,
+ typeLabel,
+ formFields,
+ initialValues,
+ onSubmit,
+}: MetadataFormDialogProps) {
+ const fields = formFields ?? DEFAULT_FORM_FIELDS;
+ const isEdit = mode === 'edit';
+
+ // Form state
+ const [values, setValues] = useState>({});
+ const [submitting, setSubmitting] = useState(false);
+
+ // Reset form values when dialog opens or initialValues change
+ useEffect(() => {
+ if (open) {
+ const initial: Record = {};
+ for (const f of fields) {
+ initial[f.key] = String(initialValues?.[f.key] ?? '');
+ }
+ setValues(initial);
+ }
+ }, [open, initialValues, fields]);
+
+ const handleChange = useCallback((key: string, value: string) => {
+ setValues((prev) => ({ ...prev, [key]: value }));
+ }, []);
+
+ const handleSubmit = useCallback(async () => {
+ setSubmitting(true);
+ try {
+ await onSubmit(values);
+ onOpenChange(false);
+ } catch {
+ // Error handling is done by the caller (toast, etc.)
+ } finally {
+ setSubmitting(false);
+ }
+ }, [values, onSubmit, onOpenChange]);
+
+ // Validate required fields
+ const isValid = fields
+ .filter((f) => f.required)
+ .every((f) => (values[f.key] ?? '').trim().length > 0);
+
+ return (
+
+ );
+}
diff --git a/apps/console/src/config/metadataTypeRegistry.ts b/apps/console/src/config/metadataTypeRegistry.ts
index eed4fb9af..5cd849466 100644
--- a/apps/console/src/config/metadataTypeRegistry.ts
+++ b/apps/console/src/config/metadataTypeRegistry.ts
@@ -18,6 +18,8 @@
* @module config/metadataTypeRegistry
*/
+import type React from 'react';
+
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -32,6 +34,27 @@ export interface MetadataColumnDef {
width?: string;
}
+/** Form field definition used by MetadataFormDialog for create/edit forms. */
+export interface MetadataFormFieldDef {
+ /** Field key in the metadata item (e.g. `'name'`, `'label'`). */
+ key: string;
+ /** Human-readable label shown next to the input. */
+ label: string;
+ /** Input type. Defaults to `'text'`. */
+ type?: 'text' | 'textarea' | 'select';
+ /** Placeholder text for the input. */
+ placeholder?: string;
+ /** Whether the field is required. Defaults to `false`. */
+ required?: boolean;
+ /**
+ * Whether the field is disabled when editing an existing item.
+ * Useful for immutable keys like `name`. Defaults to `false`.
+ */
+ disabledOnEdit?: boolean;
+ /** Options for `type: 'select'`. */
+ options?: { label: string; value: string }[];
+}
+
/** Full configuration for a single metadata type. */
export interface MetadataTypeConfig {
/**
@@ -58,6 +81,12 @@ export interface MetadataTypeConfig {
*/
columns?: MetadataColumnDef[];
+ /**
+ * Form field definitions for the create/edit dialog.
+ * When omitted, the dialog shows `name`, `label`, and `description` fields.
+ */
+ formFields?: MetadataFormFieldDef[];
+
/**
* If `true`, this type already has a dedicated management page and should
* NOT generate a `/system/metadata/:type` route. The hub card will link
@@ -90,6 +119,13 @@ export interface MetadataTypeConfig {
* Defaults to `true`. Set to `false` for read-only types (e.g. audit log).
*/
editable?: boolean;
+
+ /**
+ * Optional custom React component for the detail page.
+ * When set, the detail page will render this component instead of the
+ * generic layout.
+ */
+ detailComponent?: React.ComponentType<{ item: Record }>;
}
// ---------------------------------------------------------------------------
@@ -138,6 +174,11 @@ export const METADATA_TYPES: MetadataTypeConfig[] = [
{ key: 'label', label: 'Label' },
{ key: 'description', label: 'Description' },
],
+ formFields: [
+ { key: 'name', label: 'Name', required: true, placeholder: 'my_dashboard', disabledOnEdit: true },
+ { key: 'label', label: 'Label', required: true, placeholder: 'My Dashboard' },
+ { key: 'description', label: 'Description', type: 'textarea', placeholder: 'Brief description' },
+ ],
},
{
type: 'page',
@@ -150,6 +191,11 @@ export const METADATA_TYPES: MetadataTypeConfig[] = [
{ key: 'label', label: 'Label' },
{ key: 'description', label: 'Description' },
],
+ formFields: [
+ { key: 'name', label: 'Name', required: true, placeholder: 'my_page', disabledOnEdit: true },
+ { key: 'label', label: 'Label', required: true, placeholder: 'My Page' },
+ { key: 'description', label: 'Description', type: 'textarea', placeholder: 'Brief description' },
+ ],
},
{
type: 'report',
@@ -162,6 +208,11 @@ export const METADATA_TYPES: MetadataTypeConfig[] = [
{ key: 'label', label: 'Label' },
{ key: 'description', label: 'Description' },
],
+ formFields: [
+ { key: 'name', label: 'Name', required: true, placeholder: 'my_report', disabledOnEdit: true },
+ { key: 'label', label: 'Label', required: true, placeholder: 'My Report' },
+ { key: 'description', label: 'Description', type: 'textarea', placeholder: 'Brief description' },
+ ],
},
];
@@ -191,3 +242,13 @@ export function getGenericMetadataTypes(): MetadataTypeConfig[] {
export function getHubMetadataTypes(): MetadataTypeConfig[] {
return METADATA_TYPES;
}
+
+/**
+ * Default form fields used by MetadataFormDialog when a type does not define
+ * its own `formFields`. Covers the `name`, `label`, and `description` fields.
+ */
+export const DEFAULT_FORM_FIELDS: MetadataFormFieldDef[] = [
+ { key: 'name', label: 'Name', required: true, placeholder: 'api_name', disabledOnEdit: true },
+ { key: 'label', label: 'Label', required: true, placeholder: 'Display Label' },
+ { key: 'description', label: 'Description', type: 'textarea', placeholder: 'Brief description' },
+];
diff --git a/apps/console/src/pages/system/MetadataDetailPage.tsx b/apps/console/src/pages/system/MetadataDetailPage.tsx
new file mode 100644
index 000000000..fcd706c10
--- /dev/null
+++ b/apps/console/src/pages/system/MetadataDetailPage.tsx
@@ -0,0 +1,241 @@
+/**
+ * MetadataDetailPage
+ *
+ * Generic, registry-driven detail page for viewing a single metadata item.
+ * Supports editing via the MetadataFormDialog and custom detail renderers
+ * via the registry's `detailComponent` configuration.
+ *
+ * Route: `/system/metadata/:metadataType/:itemName`
+ *
+ * @module pages/system/MetadataDetailPage
+ */
+
+import { useState, useCallback, useEffect } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import {
+ Button,
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ Badge,
+} from '@object-ui/components';
+import {
+ ArrowLeft,
+ Pencil,
+ 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, DEFAULT_FORM_FIELDS, type MetadataTypeConfig } from '../../config/metadataTypeRegistry';
+import { MetadataFormDialog } from '../../components/MetadataFormDialog';
+
+// ---------------------------------------------------------------------------
+// Icon resolver (same as MetadataManagerPage)
+// ---------------------------------------------------------------------------
+
+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 MetadataDetailPage() {
+ const navigate = useNavigate();
+ const { appName, metadataType, itemName } = useParams<{
+ appName?: string;
+ metadataType: string;
+ itemName: string;
+ }>();
+ const basePath = appName ? `/apps/${appName}` : '';
+ const metadataService = useMetadataService();
+ const { refresh } = useMetadata();
+
+ const config: MetadataTypeConfig | undefined = metadataType
+ ? getMetadataTypeConfig(metadataType)
+ : undefined;
+
+ const [item, setItem] = useState | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [editOpen, setEditOpen] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ // Fetch the single item by finding it in the list
+ const fetchItem = useCallback(async () => {
+ if (!metadataService || !metadataType || !itemName) return;
+ setLoading(true);
+ try {
+ const items = await metadataService.getItems(metadataType);
+ const found = items.find(
+ (i) => String(i.name) === itemName && !i._deleted,
+ );
+ setItem(found ?? null);
+ } catch {
+ toast.error(`Failed to load ${config?.label ?? metadataType}`);
+ } finally {
+ setLoading(false);
+ }
+ }, [metadataService, metadataType, itemName, config?.label]);
+
+ useEffect(() => {
+ fetchItem();
+ }, [fetchItem]);
+
+ // Save handler for the edit dialog
+ const handleSave = useCallback(
+ async (values: Record) => {
+ if (!metadataService || !metadataType || !itemName) return;
+ setSaving(true);
+ try {
+ const data: Record = { ...item, ...values };
+ await metadataService.saveMetadataItem(metadataType, itemName, data);
+ await refresh();
+ await fetchItem();
+ toast.success(`${config?.label ?? 'Item'} "${itemName}" updated`);
+ } catch {
+ toast.error(`Failed to update "${itemName}"`);
+ throw new Error('save failed');
+ } finally {
+ setSaving(false);
+ }
+ },
+ [metadataService, metadataType, itemName, item, config?.label, refresh, fetchItem],
+ );
+
+ const listPath = `${basePath}/system/metadata/${metadataType ?? ''}`;
+
+ // Unknown type guard
+ if (!config) {
+ return (
+
+
+
Unknown metadata type: {metadataType}
+
+
+
+ );
+ }
+
+ const Icon = resolveIcon(config.icon);
+ const isEditable = config.editable !== false;
+ const fields = config.formFields ?? DEFAULT_FORM_FIELDS;
+ const CustomDetail = config.detailComponent;
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+ {loading ? 'Loading...' : String(item?.label ?? item?.name ?? itemName)}
+
+
{config.label} Details
+
+
+
+ {isEditable && !loading && item && (
+
+ )}
+
+
+ {/* Loading */}
+ {loading && (
+
+
+ Loading...
+
+ )}
+
+ {/* Not found */}
+ {!loading && !item && (
+
+
+ {config.label} "{itemName}" not found.
+
+
+
+ )}
+
+ {/* Detail content */}
+ {!loading && item && (
+ <>
+ {CustomDetail ? (
+
+ ) : (
+
+
+
+ {config.label}
+ {String(item.name ?? '')}
+
+
+
+
+ {fields.map((field) => (
+
+
- {field.label}
+ - {String(item[field.key] ?? '—')}
+
+ ))}
+
+
+
+ )}
+ >
+ )}
+
+ {/* Edit dialog */}
+ {isEditable && item && (
+
+ )}
+
+ );
+}
diff --git a/apps/console/src/pages/system/MetadataManagerPage.tsx b/apps/console/src/pages/system/MetadataManagerPage.tsx
index 1b4e3eecb..037c88823 100644
--- a/apps/console/src/pages/system/MetadataManagerPage.tsx
+++ b/apps/console/src/pages/system/MetadataManagerPage.tsx
@@ -10,6 +10,7 @@
* - Create / Edit / Delete with optimistic UI + toast feedback
* - Search filtering by name / label
* - Back-to-hub navigation
+ * - Click item name to navigate to detail page
*
* @module pages/system/MetadataManagerPage
*/
@@ -40,6 +41,7 @@ import { toast } from 'sonner';
import { useMetadataService } from '../../hooks/useMetadataService';
import { useMetadata } from '../../context/MetadataProvider';
import { getMetadataTypeConfig, type MetadataTypeConfig } from '../../config/metadataTypeRegistry';
+import { MetadataFormDialog } from '../../components/MetadataFormDialog';
// ---------------------------------------------------------------------------
// Icon resolver
@@ -80,6 +82,11 @@ export function MetadataManagerPage() {
const [saving, setSaving] = useState(false);
const [deletingName, setDeletingName] = useState(null);
+ // Form dialog state
+ const [formOpen, setFormOpen] = useState(false);
+ const [formMode, setFormMode] = useState<'create' | 'edit'>('create');
+ const [editingItem, setEditingItem] = useState | null>(null);
+
// Fetch items
const fetchItems = useCallback(async () => {
if (!metadataService || !metadataType) return;
@@ -132,6 +139,47 @@ export function MetadataManagerPage() {
}
}, [metadataService, metadataType, deletingName, config?.label, refresh]);
+ const handleCreate = useCallback(() => {
+ setEditingItem(null);
+ setFormMode('create');
+ setFormOpen(true);
+ }, []);
+
+ const handleEdit = useCallback((item: Record) => {
+ setEditingItem(item);
+ setFormMode('edit');
+ setFormOpen(true);
+ }, []);
+
+ const handleFormSubmit = useCallback(async (values: Record) => {
+ if (!metadataService || !metadataType) return;
+ const name = values.name ?? String(editingItem?.name ?? '');
+ if (!name) return;
+ setSaving(true);
+ try {
+ const data: Record = formMode === 'edit'
+ ? { ...editingItem, ...values }
+ : { ...values };
+ await metadataService.saveMetadataItem(metadataType, name, data);
+ await refresh();
+ await fetchItems();
+ toast.success(
+ formMode === 'edit'
+ ? `${config?.label ?? 'Item'} "${name}" updated`
+ : `${config?.label ?? 'Item'} "${name}" created`,
+ );
+ } catch {
+ toast.error(
+ formMode === 'edit'
+ ? `Failed to update "${name}"`
+ : `Failed to create "${name}"`,
+ );
+ throw new Error('save failed');
+ } finally {
+ setSaving(false);
+ }
+ }, [metadataService, metadataType, editingItem, formMode, config?.label, refresh, fetchItems]);
+
// Unknown type guard
if (!config) {
return (
@@ -178,6 +226,13 @@ export function MetadataManagerPage() {
+
+ {isEditable && (
+
+ )}
{/* Search */}
@@ -229,8 +284,11 @@ export function MetadataManagerPage() {
return (
+ navigate(`${basePath}/system/metadata/${metadataType}/${name}`)
+ }
>
@@ -242,11 +300,27 @@ export function MetadataManagerPage() {
{isEditable && (
+
)}
+
+ {/* Create/Edit dialog */}
+ {isEditable && (
+
+ )}
);
}
From e76e80576054074975b07ba73247742bc680cba2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 7 Apr 2026 03:48:40 +0000
Subject: [PATCH 3/5] fix: improve error messages and placeholder naming per
review feedback
Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/c72f47f2-b9dd-4364-b613-535fb3b9d405
Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com>
---
apps/console/src/config/metadataTypeRegistry.ts | 6 +++---
apps/console/src/pages/system/MetadataDetailPage.tsx | 2 +-
apps/console/src/pages/system/MetadataManagerPage.tsx | 2 +-
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/apps/console/src/config/metadataTypeRegistry.ts b/apps/console/src/config/metadataTypeRegistry.ts
index 5cd849466..0daddafca 100644
--- a/apps/console/src/config/metadataTypeRegistry.ts
+++ b/apps/console/src/config/metadataTypeRegistry.ts
@@ -175,7 +175,7 @@ export const METADATA_TYPES: MetadataTypeConfig[] = [
{ key: 'description', label: 'Description' },
],
formFields: [
- { key: 'name', label: 'Name', required: true, placeholder: 'my_dashboard', disabledOnEdit: true },
+ { key: 'name', label: 'Name', required: true, placeholder: 'dashboard_name', disabledOnEdit: true },
{ key: 'label', label: 'Label', required: true, placeholder: 'My Dashboard' },
{ key: 'description', label: 'Description', type: 'textarea', placeholder: 'Brief description' },
],
@@ -192,7 +192,7 @@ export const METADATA_TYPES: MetadataTypeConfig[] = [
{ key: 'description', label: 'Description' },
],
formFields: [
- { key: 'name', label: 'Name', required: true, placeholder: 'my_page', disabledOnEdit: true },
+ { key: 'name', label: 'Name', required: true, placeholder: 'page_name', disabledOnEdit: true },
{ key: 'label', label: 'Label', required: true, placeholder: 'My Page' },
{ key: 'description', label: 'Description', type: 'textarea', placeholder: 'Brief description' },
],
@@ -209,7 +209,7 @@ export const METADATA_TYPES: MetadataTypeConfig[] = [
{ key: 'description', label: 'Description' },
],
formFields: [
- { key: 'name', label: 'Name', required: true, placeholder: 'my_report', disabledOnEdit: true },
+ { key: 'name', label: 'Name', required: true, placeholder: 'report_name', disabledOnEdit: true },
{ key: 'label', label: 'Label', required: true, placeholder: 'My Report' },
{ key: 'description', label: 'Description', type: 'textarea', placeholder: 'Brief description' },
],
diff --git a/apps/console/src/pages/system/MetadataDetailPage.tsx b/apps/console/src/pages/system/MetadataDetailPage.tsx
index fcd706c10..316c4a9b1 100644
--- a/apps/console/src/pages/system/MetadataDetailPage.tsx
+++ b/apps/console/src/pages/system/MetadataDetailPage.tsx
@@ -110,7 +110,7 @@ export function MetadataDetailPage() {
toast.success(`${config?.label ?? 'Item'} "${itemName}" updated`);
} catch {
toast.error(`Failed to update "${itemName}"`);
- throw new Error('save failed');
+ throw new Error(`Failed to save ${config?.label ?? 'item'} "${itemName}"`);
} finally {
setSaving(false);
}
diff --git a/apps/console/src/pages/system/MetadataManagerPage.tsx b/apps/console/src/pages/system/MetadataManagerPage.tsx
index 037c88823..c3a238332 100644
--- a/apps/console/src/pages/system/MetadataManagerPage.tsx
+++ b/apps/console/src/pages/system/MetadataManagerPage.tsx
@@ -174,7 +174,7 @@ export function MetadataManagerPage() {
? `Failed to update "${name}"`
: `Failed to create "${name}"`,
);
- throw new Error('save failed');
+ throw new Error(`Failed to save ${config?.label ?? 'item'} "${name}"`);
} finally {
setSaving(false);
}
From a6873f9bfed3c80d8d324a7b72eb97769b317717 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 7 Apr 2026 04:01:54 +0000
Subject: [PATCH 4/5] feat: add P2 actions field, permission integration for
metadata pages
- Add MetadataActionDef type and actions field to MetadataTypeConfig
- MetadataManagerPage renders page-level and row-level custom actions
- MetadataManagerPage and MetadataDetailPage use useAuth() to check admin role
- Create/edit/delete buttons hidden for non-admin users
- Add tests for permission integration and actions support
- Update CHANGELOG.md
Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/fdb1f248-f420-4b3f-b38e-7c2e71034ac4
Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com>
---
CHANGELOG.md | 6 +-
.../src/__tests__/MetadataDetailPage.test.tsx | 4 ++
.../__tests__/MetadataManagerPage.test.tsx | 18 +++++
.../__tests__/metadataTypeRegistry.test.ts | 72 +++++++++++++++++++
.../src/config/metadataTypeRegistry.ts | 27 +++++++
.../src/pages/system/MetadataDetailPage.tsx | 7 +-
.../src/pages/system/MetadataManagerPage.tsx | 38 +++++++++-
7 files changed, 169 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b27ce185..aa627dbc4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,7 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **MetadataDetailPage** (`@object-ui/console`): New detail page component (`pages/system/MetadataDetailPage.tsx`) for viewing a single metadata item at `/system/metadata/:metadataType/:itemName`. Displays item fields from the registry's `formFields` config, supports editing via MetadataFormDialog, and allows custom detail renderers via the registry's `detailComponent` property.
-- **Registry extensions** (`@object-ui/console`): Extended `MetadataTypeConfig` interface with `formFields` (standardized create/edit form structure), `detailComponent` (custom detail renderers), and `MetadataFormFieldDef` type. Added `formFields` entries for dashboard, page, and report types. Added `DEFAULT_FORM_FIELDS` constant for types without explicit form configuration.
+- **Registry extensions** (`@object-ui/console`): Extended `MetadataTypeConfig` interface with `formFields` (standardized create/edit form structure), `detailComponent` (custom detail renderers), `actions` (custom page-level and row-level action buttons via `MetadataActionDef`), and `MetadataFormFieldDef` type. Added `formFields` entries for dashboard, page, and report types. Added `DEFAULT_FORM_FIELDS` constant for types without explicit form configuration.
+
+- **Permission integration** (`@object-ui/console`): MetadataManagerPage and MetadataDetailPage now check the current user's role via `useAuth()`. Create, edit, and delete buttons are only shown for admin users (`user.role === 'admin'`), matching the pattern used by UserManagementPage, RoleManagementPage, and other system pages.
+
+- **Custom actions support** (`@object-ui/console`): MetadataManagerPage renders page-level and row-level custom action buttons from the registry's `actions` configuration. Page-level actions appear in the header alongside the create button; row-level actions appear on each item card alongside edit/delete.
- **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.
diff --git a/apps/console/src/__tests__/MetadataDetailPage.test.tsx b/apps/console/src/__tests__/MetadataDetailPage.test.tsx
index 271ec06d4..32c15592f 100644
--- a/apps/console/src/__tests__/MetadataDetailPage.test.tsx
+++ b/apps/console/src/__tests__/MetadataDetailPage.test.tsx
@@ -39,6 +39,10 @@ vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
+vi.mock('@object-ui/auth', () => ({
+ useAuth: () => ({ user: { id: 'u1', name: 'Admin', role: 'admin' } }),
+}));
+
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
diff --git a/apps/console/src/__tests__/MetadataManagerPage.test.tsx b/apps/console/src/__tests__/MetadataManagerPage.test.tsx
index f89206663..751482484 100644
--- a/apps/console/src/__tests__/MetadataManagerPage.test.tsx
+++ b/apps/console/src/__tests__/MetadataManagerPage.test.tsx
@@ -41,6 +41,10 @@ vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
+vi.mock('@object-ui/auth', () => ({
+ useAuth: () => ({ user: { id: 'u1', name: 'Admin', role: 'admin' } }),
+}));
+
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
@@ -328,4 +332,18 @@ describe('MetadataManagerPage', () => {
expect(mockNavigate).toHaveBeenCalledWith('/system/metadata/dashboard/sales_dash');
});
});
+
+ describe('permission integration', () => {
+ it('should show create/edit/delete buttons for admin users (mocked as admin)', async () => {
+ mockGetItems.mockResolvedValue([
+ { name: 'test_dash', label: 'Test Dashboard' },
+ ]);
+ renderWithRoute('dashboard');
+ await waitFor(() => {
+ expect(screen.getByTestId('create-metadata-btn')).toBeInTheDocument();
+ expect(screen.getByTestId('edit-test_dash-btn')).toBeInTheDocument();
+ expect(screen.getByTestId('delete-test_dash-btn')).toBeInTheDocument();
+ });
+ });
+ });
});
diff --git a/apps/console/src/__tests__/metadataTypeRegistry.test.ts b/apps/console/src/__tests__/metadataTypeRegistry.test.ts
index ff8e78689..72437f708 100644
--- a/apps/console/src/__tests__/metadataTypeRegistry.test.ts
+++ b/apps/console/src/__tests__/metadataTypeRegistry.test.ts
@@ -10,7 +10,10 @@ import {
getMetadataTypeConfig,
getGenericMetadataTypes,
getHubMetadataTypes,
+ DEFAULT_FORM_FIELDS,
type MetadataTypeConfig,
+ type MetadataFormFieldDef,
+ type MetadataActionDef,
} from '../config/metadataTypeRegistry';
describe('metadataTypeRegistry', () => {
@@ -94,4 +97,73 @@ describe('metadataTypeRegistry', () => {
expect(hub.length).toBe(METADATA_TYPES.length);
});
});
+
+ describe('formFields', () => {
+ it('should have formFields on generic metadata types', () => {
+ const generic = getGenericMetadataTypes();
+ for (const entry of generic) {
+ expect(entry.formFields).toBeDefined();
+ expect(entry.formFields!.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('should include name, label, and description fields', () => {
+ const dashboard = getMetadataTypeConfig('dashboard')!;
+ const keys = dashboard.formFields!.map((f) => f.key);
+ expect(keys).toContain('name');
+ expect(keys).toContain('label');
+ expect(keys).toContain('description');
+ });
+
+ it('should mark name as disabledOnEdit', () => {
+ const dashboard = getMetadataTypeConfig('dashboard')!;
+ const nameField = dashboard.formFields!.find((f) => f.key === 'name')!;
+ expect(nameField.disabledOnEdit).toBe(true);
+ });
+ });
+
+ describe('DEFAULT_FORM_FIELDS', () => {
+ it('should contain name, label, and description', () => {
+ const keys = DEFAULT_FORM_FIELDS.map((f) => f.key);
+ expect(keys).toEqual(['name', 'label', 'description']);
+ });
+
+ it('should mark name and label as required', () => {
+ const name = DEFAULT_FORM_FIELDS.find((f) => f.key === 'name')!;
+ const label = DEFAULT_FORM_FIELDS.find((f) => f.key === 'label')!;
+ expect(name.required).toBe(true);
+ expect(label.required).toBe(true);
+ });
+ });
+
+ describe('MetadataTypeConfig type support', () => {
+ it('should accept actions field in MetadataTypeConfig', () => {
+ const config: MetadataTypeConfig = {
+ type: 'test',
+ label: 'Test',
+ pluralLabel: 'Tests',
+ description: 'Test type',
+ icon: 'database',
+ actions: [
+ { key: 'export', label: 'Export', scope: 'page' },
+ { key: 'view', label: 'View', scope: 'row' },
+ ],
+ };
+ expect(config.actions).toHaveLength(2);
+ expect(config.actions![0].scope).toBe('page');
+ expect(config.actions![1].scope).toBe('row');
+ });
+
+ it('should accept detailComponent field in MetadataTypeConfig', () => {
+ const config: MetadataTypeConfig = {
+ type: 'test',
+ label: 'Test',
+ pluralLabel: 'Tests',
+ description: 'Test type',
+ icon: 'database',
+ detailComponent: () => null,
+ };
+ expect(config.detailComponent).toBeDefined();
+ });
+ });
});
diff --git a/apps/console/src/config/metadataTypeRegistry.ts b/apps/console/src/config/metadataTypeRegistry.ts
index 0daddafca..ae5cae7ad 100644
--- a/apps/console/src/config/metadataTypeRegistry.ts
+++ b/apps/console/src/config/metadataTypeRegistry.ts
@@ -55,6 +55,26 @@ export interface MetadataFormFieldDef {
options?: { label: string; value: string }[];
}
+/** Action definition for custom page-level or row-level buttons. */
+export interface MetadataActionDef {
+ /** Unique key for the action. */
+ key: string;
+ /** Human-readable label shown on the button. */
+ label: string;
+ /** Lucide icon name (lowercase, hyphenated). */
+ icon?: string;
+ /** Whether this action appears on each row (`'row'`) or at the page level (`'page'`). */
+ scope: 'page' | 'row';
+ /** Action variant for styling (e.g. `'default'`, `'destructive'`, `'outline'`). */
+ variant?: 'default' | 'destructive' | 'outline' | 'ghost' | 'secondary';
+ /**
+ * Handler called when the action is triggered.
+ * - For `scope: 'page'`: called with no arguments.
+ * - For `scope: 'row'`: called with the metadata item.
+ */
+ handler?: (item?: Record) => void;
+}
+
/** Full configuration for a single metadata type. */
export interface MetadataTypeConfig {
/**
@@ -126,6 +146,13 @@ export interface MetadataTypeConfig {
* generic layout.
*/
detailComponent?: React.ComponentType<{ item: Record }>;
+
+ /**
+ * Custom action definitions for page-level and row-level buttons.
+ * Page-level actions are rendered in the header area; row-level actions
+ * appear alongside each item's edit/delete buttons.
+ */
+ actions?: MetadataActionDef[];
}
// ---------------------------------------------------------------------------
diff --git a/apps/console/src/pages/system/MetadataDetailPage.tsx b/apps/console/src/pages/system/MetadataDetailPage.tsx
index 316c4a9b1..f0956d976 100644
--- a/apps/console/src/pages/system/MetadataDetailPage.tsx
+++ b/apps/console/src/pages/system/MetadataDetailPage.tsx
@@ -31,6 +31,7 @@ import {
LayoutGrid,
} from 'lucide-react';
import { toast } from 'sonner';
+import { useAuth } from '@object-ui/auth';
import { useMetadataService } from '../../hooks/useMetadataService';
import { useMetadata } from '../../context/MetadataProvider';
import { getMetadataTypeConfig, DEFAULT_FORM_FIELDS, type MetadataTypeConfig } from '../../config/metadataTypeRegistry';
@@ -66,6 +67,10 @@ export function MetadataDetailPage() {
const basePath = appName ? `/apps/${appName}` : '';
const metadataService = useMetadataService();
const { refresh } = useMetadata();
+ const { user } = useAuth();
+
+ // Permission: only admin users can mutate metadata
+ const isAdmin = user?.role === 'admin';
const config: MetadataTypeConfig | undefined = metadataType
? getMetadataTypeConfig(metadataType)
@@ -135,7 +140,7 @@ export function MetadataDetailPage() {
}
const Icon = resolveIcon(config.icon);
- const isEditable = config.editable !== false;
+ const isEditable = config.editable !== false && isAdmin;
const fields = config.formFields ?? DEFAULT_FORM_FIELDS;
const CustomDetail = config.detailComponent;
diff --git a/apps/console/src/pages/system/MetadataManagerPage.tsx b/apps/console/src/pages/system/MetadataManagerPage.tsx
index c3a238332..510bdf024 100644
--- a/apps/console/src/pages/system/MetadataManagerPage.tsx
+++ b/apps/console/src/pages/system/MetadataManagerPage.tsx
@@ -38,6 +38,7 @@ import {
LayoutGrid,
} from 'lucide-react';
import { toast } from 'sonner';
+import { useAuth } from '@object-ui/auth';
import { useMetadataService } from '../../hooks/useMetadataService';
import { useMetadata } from '../../context/MetadataProvider';
import { getMetadataTypeConfig, type MetadataTypeConfig } from '../../config/metadataTypeRegistry';
@@ -69,6 +70,10 @@ export function MetadataManagerPage() {
const basePath = appName ? `/apps/${appName}` : '';
const metadataService = useMetadataService();
const { refresh } = useMetadata();
+ const { user } = useAuth();
+
+ // Permission: only admin users can mutate metadata
+ const isAdmin = user?.role === 'admin';
// Resolve registry config
const config: MetadataTypeConfig | undefined = metadataType
@@ -199,7 +204,9 @@ export function MetadataManagerPage() {
}
const Icon = resolveIcon(config.icon);
- const isEditable = config.editable !== false;
+ const isEditable = config.editable !== false && isAdmin;
+ const pageActions = (config.actions ?? []).filter((a) => a.scope === 'page');
+ const rowActions = (config.actions ?? []).filter((a) => a.scope === 'row');
return (
@@ -233,6 +240,20 @@ export function MetadataManagerPage() {
New {config.label}
)}
+ {pageActions.length > 0 && (
+
+ {pageActions.map((action) => (
+
+ ))}
+
+ )}
{/* Search */}
@@ -300,6 +321,21 @@ export function MetadataManagerPage() {
{isEditable && (
+ {rowActions.map((action) => (
+
+ ))}