Skip to content

Commit 7071bcb

Browse files
Merge pull request #1188 from objectstack-ai/copilot/fix-254823548-1133319012-7b5d238a-59c4-4340-9057-4e002830fc7e
feat: metadata CRUD, form dialog, detail page, registry extensions, actions & permissions
2 parents 3fec1a7 + 523c10d commit 7071bcb

10 files changed

Lines changed: 1215 additions & 4 deletions

CHANGELOG.md

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

1010
### Added
1111

12+
- **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.
13+
14+
- **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.
15+
16+
- **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.
17+
18+
- **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.
19+
20+
- **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.
21+
22+
- **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.
23+
1224
- **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.
1325

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

apps/console/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const SystemHubPage = lazy(() => import('./pages/system/SystemHubPage').then(m =
4949
const AppManagementPage = lazy(() => import('./pages/system/AppManagementPage').then(m => ({ default: m.AppManagementPage })));
5050
const ObjectManagerPage = lazy(() => import('./pages/system/ObjectManagerPage').then(m => ({ default: m.ObjectManagerPage })));
5151
const MetadataManagerPage = lazy(() => import('./pages/system/MetadataManagerPage').then(m => ({ default: m.MetadataManagerPage })));
52+
const MetadataDetailPage = lazy(() => import('./pages/system/MetadataDetailPage').then(m => ({ default: m.MetadataDetailPage })));
5253
const UserManagementPage = lazy(() => import('./pages/system/UserManagementPage').then(m => ({ default: m.UserManagementPage })));
5354
const OrgManagementPage = lazy(() => import('./pages/system/OrgManagementPage').then(m => ({ default: m.OrgManagementPage })));
5455
const RoleManagementPage = lazy(() => import('./pages/system/RoleManagementPage').then(m => ({ default: m.RoleManagementPage })));
@@ -302,6 +303,7 @@ export function AppContent() {
302303
<Route path="system/audit-log" element={<AuditLogPage />} />
303304
<Route path="system/profile" element={<ProfilePage />} />
304305
<Route path="system/metadata/:metadataType" element={<MetadataManagerPage />} />
306+
<Route path="system/metadata/:metadataType/:itemName" element={<MetadataDetailPage />} />
305307
</Routes>
306308
</Suspense>
307309
);
@@ -397,6 +399,7 @@ export function AppContent() {
397399
<Route path="system/audit-log" element={<AuditLogPage />} />
398400
<Route path="system/profile" element={<ProfilePage />} />
399401
<Route path="system/metadata/:metadataType" element={<MetadataManagerPage />} />
402+
<Route path="system/metadata/:metadataType/:itemName" element={<MetadataDetailPage />} />
400403
</Routes>
401404
</Suspense>
402405
</ErrorBoundary>
@@ -492,6 +495,7 @@ function SystemRoutes() {
492495
<Route path="audit-log" element={<AuditLogPage />} />
493496
<Route path="profile" element={<ProfilePage />} />
494497
<Route path="metadata/:metadataType" element={<MetadataManagerPage />} />
498+
<Route path="metadata/:metadataType/:itemName" element={<MetadataDetailPage />} />
495499
</Routes>
496500
</Suspense>
497501
);
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* MetadataDetailPage Tests
3+
*
4+
* Tests for the generic, registry-driven metadata detail page that shows
5+
* a single metadata item and supports editing via the MetadataFormDialog.
6+
*/
7+
8+
import { describe, it, expect, vi, beforeEach } from 'vitest';
9+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
10+
import '@testing-library/jest-dom';
11+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
12+
13+
// --- Mock MetadataService ---
14+
const mockGetItems = vi.fn().mockResolvedValue([]);
15+
const mockSaveMetadataItem = vi.fn().mockResolvedValue(undefined);
16+
17+
vi.mock('../hooks/useMetadataService', () => ({
18+
useMetadataService: () => ({
19+
getItems: mockGetItems,
20+
saveMetadataItem: mockSaveMetadataItem,
21+
}),
22+
}));
23+
24+
const mockRefresh = vi.fn().mockResolvedValue(undefined);
25+
vi.mock('../context/MetadataProvider', () => ({
26+
useMetadata: () => ({
27+
apps: [],
28+
objects: [],
29+
dashboards: [],
30+
reports: [],
31+
pages: [],
32+
loading: false,
33+
error: null,
34+
refresh: mockRefresh,
35+
}),
36+
}));
37+
38+
vi.mock('sonner', () => ({
39+
toast: { success: vi.fn(), error: vi.fn() },
40+
}));
41+
42+
vi.mock('@object-ui/auth', () => ({
43+
useAuth: () => ({ user: { id: 'u1', name: 'Admin', role: 'admin' } }),
44+
}));
45+
46+
const mockNavigate = vi.fn();
47+
vi.mock('react-router-dom', async () => {
48+
const actual = await vi.importActual('react-router-dom');
49+
return {
50+
...actual,
51+
useNavigate: () => mockNavigate,
52+
};
53+
});
54+
55+
// Import after mocks
56+
import { MetadataDetailPage } from '../pages/system/MetadataDetailPage';
57+
import { toast } from 'sonner';
58+
59+
function renderWithRoute(metadataType: string, itemName: string) {
60+
return render(
61+
<MemoryRouter initialEntries={[`/system/metadata/${metadataType}/${itemName}`]}>
62+
<Routes>
63+
<Route
64+
path="/system/metadata/:metadataType/:itemName"
65+
element={<MetadataDetailPage />}
66+
/>
67+
</Routes>
68+
</MemoryRouter>,
69+
);
70+
}
71+
72+
beforeEach(() => {
73+
vi.clearAllMocks();
74+
mockGetItems.mockResolvedValue([]);
75+
});
76+
77+
describe('MetadataDetailPage', () => {
78+
describe('with known type and existing item', () => {
79+
beforeEach(() => {
80+
mockGetItems.mockResolvedValue([
81+
{ name: 'sales_dash', label: 'Sales Dashboard', description: 'Sales KPIs' },
82+
{ name: 'ops_dash', label: 'Operations', description: 'Ops overview' },
83+
]);
84+
});
85+
86+
it('should render item details', async () => {
87+
renderWithRoute('dashboard', 'sales_dash');
88+
await waitFor(() => {
89+
expect(screen.getByTestId('detail-card')).toBeInTheDocument();
90+
});
91+
// "Sales Dashboard" appears in both heading and detail card
92+
expect(screen.getAllByText('Sales Dashboard').length).toBeGreaterThanOrEqual(1);
93+
});
94+
95+
it('should show edit button', async () => {
96+
renderWithRoute('dashboard', 'sales_dash');
97+
await waitFor(() => {
98+
expect(screen.getByTestId('detail-edit-btn')).toBeInTheDocument();
99+
});
100+
});
101+
102+
it('should show back button that navigates to list', async () => {
103+
renderWithRoute('dashboard', 'sales_dash');
104+
await waitFor(() => {
105+
expect(screen.getByTestId('back-to-list-btn')).toBeInTheDocument();
106+
});
107+
fireEvent.click(screen.getByTestId('back-to-list-btn'));
108+
expect(mockNavigate).toHaveBeenCalledWith('/system/metadata/dashboard');
109+
});
110+
111+
it('should open edit dialog when edit button clicked', async () => {
112+
renderWithRoute('dashboard', 'sales_dash');
113+
await waitFor(() => {
114+
expect(screen.getByTestId('detail-edit-btn')).toBeInTheDocument();
115+
});
116+
fireEvent.click(screen.getByTestId('detail-edit-btn'));
117+
await waitFor(() => {
118+
expect(screen.getByTestId('metadata-form-dialog')).toBeInTheDocument();
119+
});
120+
});
121+
122+
it('should display field values from the item', async () => {
123+
renderWithRoute('dashboard', 'sales_dash');
124+
await waitFor(() => {
125+
expect(screen.getByTestId('detail-card')).toBeInTheDocument();
126+
});
127+
// "sales_dash" appears in both the heading subtitle and the detail card
128+
expect(screen.getAllByText('sales_dash').length).toBeGreaterThanOrEqual(1);
129+
expect(screen.getAllByText('Sales Dashboard').length).toBeGreaterThanOrEqual(1);
130+
expect(screen.getByText('Sales KPIs')).toBeInTheDocument();
131+
});
132+
});
133+
134+
describe('with item not found', () => {
135+
it('should show not found message', async () => {
136+
mockGetItems.mockResolvedValue([]);
137+
renderWithRoute('dashboard', 'nonexistent');
138+
await waitFor(() => {
139+
expect(screen.getByTestId('detail-not-found')).toBeInTheDocument();
140+
});
141+
});
142+
});
143+
144+
describe('with unknown metadata type', () => {
145+
it('should show unknown type message', () => {
146+
renderWithRoute('nonexistent_type', 'some_item');
147+
expect(screen.getByText(/Unknown metadata type: nonexistent_type/)).toBeInTheDocument();
148+
});
149+
});
150+
151+
describe('loading state', () => {
152+
it('should show loading indicator while fetching', () => {
153+
mockGetItems.mockReturnValue(new Promise(() => {})); // never resolves
154+
renderWithRoute('dashboard', 'sales_dash');
155+
expect(screen.getByTestId('detail-loading')).toBeInTheDocument();
156+
});
157+
});
158+
});
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* MetadataFormDialog Tests
3+
*
4+
* Tests for the generic create/edit dialog driven by the metadata type
5+
* registry's `formFields` configuration.
6+
*/
7+
8+
import { describe, it, expect, vi, beforeEach } from 'vitest';
9+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
10+
import '@testing-library/jest-dom';
11+
import { MetadataFormDialog } from '../components/MetadataFormDialog';
12+
13+
beforeEach(() => {
14+
vi.clearAllMocks();
15+
});
16+
17+
describe('MetadataFormDialog', () => {
18+
const defaultProps = {
19+
open: true,
20+
onOpenChange: vi.fn(),
21+
mode: 'create' as const,
22+
typeLabel: 'Dashboard',
23+
onSubmit: vi.fn().mockResolvedValue(undefined),
24+
};
25+
26+
describe('create mode', () => {
27+
it('should render dialog with create title', () => {
28+
render(<MetadataFormDialog {...defaultProps} />);
29+
expect(screen.getByText('New Dashboard')).toBeInTheDocument();
30+
});
31+
32+
it('should render default form fields when no formFields provided', () => {
33+
render(<MetadataFormDialog {...defaultProps} />);
34+
expect(screen.getByTestId('metadata-field-name')).toBeInTheDocument();
35+
expect(screen.getByTestId('metadata-field-label')).toBeInTheDocument();
36+
expect(screen.getByTestId('metadata-field-description')).toBeInTheDocument();
37+
});
38+
39+
it('should render custom form fields from formFields prop', () => {
40+
const formFields = [
41+
{ key: 'name', label: 'Name', required: true },
42+
{ key: 'title', label: 'Title', required: false },
43+
];
44+
render(<MetadataFormDialog {...defaultProps} formFields={formFields} />);
45+
expect(screen.getByTestId('metadata-field-name')).toBeInTheDocument();
46+
expect(screen.getByTestId('metadata-field-title')).toBeInTheDocument();
47+
expect(screen.queryByTestId('metadata-field-description')).not.toBeInTheDocument();
48+
});
49+
50+
it('should show Create button text', () => {
51+
render(<MetadataFormDialog {...defaultProps} />);
52+
expect(screen.getByTestId('metadata-form-submit-btn')).toHaveTextContent('Create');
53+
});
54+
55+
it('should disable submit when required fields are empty', () => {
56+
render(<MetadataFormDialog {...defaultProps} />);
57+
expect(screen.getByTestId('metadata-form-submit-btn')).toBeDisabled();
58+
});
59+
60+
it('should enable submit when required fields are filled', () => {
61+
render(<MetadataFormDialog {...defaultProps} />);
62+
fireEvent.change(screen.getByTestId('metadata-field-name'), {
63+
target: { value: 'my_dash' },
64+
});
65+
fireEvent.change(screen.getByTestId('metadata-field-label'), {
66+
target: { value: 'My Dash' },
67+
});
68+
expect(screen.getByTestId('metadata-form-submit-btn')).not.toBeDisabled();
69+
});
70+
71+
it('should call onSubmit with form values when submitted', async () => {
72+
const mockSubmit = vi.fn().mockResolvedValue(undefined);
73+
render(<MetadataFormDialog {...defaultProps} onSubmit={mockSubmit} />);
74+
75+
fireEvent.change(screen.getByTestId('metadata-field-name'), {
76+
target: { value: 'my_dash' },
77+
});
78+
fireEvent.change(screen.getByTestId('metadata-field-label'), {
79+
target: { value: 'My Dashboard' },
80+
});
81+
fireEvent.click(screen.getByTestId('metadata-form-submit-btn'));
82+
83+
await waitFor(() => {
84+
expect(mockSubmit).toHaveBeenCalledWith(
85+
expect.objectContaining({
86+
name: 'my_dash',
87+
label: 'My Dashboard',
88+
}),
89+
);
90+
});
91+
});
92+
93+
it('should call onOpenChange(false) after successful submit', async () => {
94+
const mockOpenChange = vi.fn();
95+
render(
96+
<MetadataFormDialog
97+
{...defaultProps}
98+
onOpenChange={mockOpenChange}
99+
/>,
100+
);
101+
102+
fireEvent.change(screen.getByTestId('metadata-field-name'), {
103+
target: { value: 'test' },
104+
});
105+
fireEvent.change(screen.getByTestId('metadata-field-label'), {
106+
target: { value: 'Test' },
107+
});
108+
fireEvent.click(screen.getByTestId('metadata-form-submit-btn'));
109+
110+
await waitFor(() => {
111+
expect(mockOpenChange).toHaveBeenCalledWith(false);
112+
});
113+
});
114+
115+
it('should close dialog when Cancel is clicked', () => {
116+
const mockOpenChange = vi.fn();
117+
render(
118+
<MetadataFormDialog
119+
{...defaultProps}
120+
onOpenChange={mockOpenChange}
121+
/>,
122+
);
123+
fireEvent.click(screen.getByTestId('metadata-form-cancel-btn'));
124+
expect(mockOpenChange).toHaveBeenCalledWith(false);
125+
});
126+
});
127+
128+
describe('edit mode', () => {
129+
const editProps = {
130+
...defaultProps,
131+
mode: 'edit' as const,
132+
initialValues: { name: 'existing_dash', label: 'Existing Dashboard', description: 'Old desc' },
133+
};
134+
135+
it('should render dialog with edit title', () => {
136+
render(<MetadataFormDialog {...editProps} />);
137+
expect(screen.getByText('Edit Dashboard')).toBeInTheDocument();
138+
});
139+
140+
it('should show Save button text', () => {
141+
render(<MetadataFormDialog {...editProps} />);
142+
expect(screen.getByTestId('metadata-form-submit-btn')).toHaveTextContent('Save');
143+
});
144+
145+
it('should pre-fill form fields with initial values', () => {
146+
render(<MetadataFormDialog {...editProps} />);
147+
expect(screen.getByTestId('metadata-field-name')).toHaveValue('existing_dash');
148+
expect(screen.getByTestId('metadata-field-label')).toHaveValue('Existing Dashboard');
149+
expect(screen.getByTestId('metadata-field-description')).toHaveValue('Old desc');
150+
});
151+
152+
it('should disable fields with disabledOnEdit in edit mode', () => {
153+
const formFields = [
154+
{ key: 'name', label: 'Name', required: true, disabledOnEdit: true },
155+
{ key: 'label', label: 'Label', required: true },
156+
];
157+
render(
158+
<MetadataFormDialog
159+
{...editProps}
160+
formFields={formFields}
161+
/>,
162+
);
163+
expect(screen.getByTestId('metadata-field-name')).toBeDisabled();
164+
expect(screen.getByTestId('metadata-field-label')).not.toBeDisabled();
165+
});
166+
});
167+
168+
describe('textarea fields', () => {
169+
it('should render textarea for fields with type textarea', () => {
170+
const formFields = [
171+
{ key: 'name', label: 'Name', required: true },
172+
{ key: 'description', label: 'Description', type: 'textarea' as const },
173+
];
174+
render(<MetadataFormDialog {...defaultProps} formFields={formFields} />);
175+
const desc = screen.getByTestId('metadata-field-description');
176+
expect(desc.tagName).toBe('TEXTAREA');
177+
});
178+
});
179+
180+
describe('when dialog is closed', () => {
181+
it('should not render dialog content when open is false', () => {
182+
render(<MetadataFormDialog {...defaultProps} open={false} />);
183+
expect(screen.queryByTestId('metadata-form-dialog')).not.toBeInTheDocument();
184+
});
185+
});
186+
});

0 commit comments

Comments
 (0)