Skip to content

Commit ab0ccf2

Browse files
authored
Merge pull request #1193 from objectstack-ai/copilot/refactor-object-manager-details
2 parents 5932d7e + 09c48e6 commit ab0ccf2

14 files changed

+912
-347
lines changed

CHANGELOG.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
- **Console app** (`@object-ui/console`): Fixed unused import warnings (`MetadataFormFieldDef`, `MetadataActionDef`, `toast`) and a `defaultValue` type mismatch (`unknown``string | undefined`) in `MetadataService.ts`.
1717

18+
### Changed
19+
20+
- **Object detail page migrated to PageSchema-driven rendering** (`@object-ui/console`): The object detail page (both at `/system/objects/:objectName` and `/system/metadata/object/:objectName`) is now rendered via `SchemaRenderer` using a `PageSchema` built by `buildObjectDetailPageSchema()`. Each section (properties, relationships, keys, data experience, data preview, field designer) is a self-contained SchemaNode widget registered in the ComponentRegistry. This replaces the monolithic `ObjectDetailView` component with a composable, metadata-driven architecture.
21+
22+
- **MetadataDetailPage unified PageSchema support** (`@object-ui/console`): `MetadataDetailPage` now supports three rendering modes in priority order: (1) PageSchema-driven via `pageSchemaFactory` in the registry config, (2) custom `detailComponent`, (3) default card layout. The `hasCustomPage` + `<Navigate>` redirect hack has been removed — all metadata detail pages are rendered directly by `MetadataDetailPage`.
23+
24+
- **MetadataTypeConfig refactored** (`@object-ui/console`): Replaced `hasCustomPage` boolean flag with the more expressive `pageSchemaFactory` function and kept `customRoute` for hub card linking. The `getGenericMetadataTypes()` helper now filters by `customRoute` instead of `hasCustomPage`.
25+
26+
- **SchemaErrorBoundary for detail pages** (`@object-ui/console`): Added `SchemaErrorBoundary` class component to both `MetadataDetailPage` and `ObjectManagerPage` to gracefully catch and display rendering errors when a PageSchema or its widgets fail to render.
27+
1828
### Added
1929

30+
- **Object detail schema widgets** (`@object-ui/console`): Six self-contained SchemaNode widget components for the object detail page, registered in ComponentRegistry: `object-properties`, `object-relationships`, `object-keys`, `object-data-experience`, `object-data-preview`, `object-field-designer`. Each widget resolves its data from React context (`useMetadata`, `useMetadataService`) making them fully composable via PageSchema.
31+
32+
- **Object detail PageSchema factory** (`@object-ui/console`): `buildObjectDetailPageSchema(objectName, item?)` generates a `PageSchema` for object detail pages. The schema defines the page structure as an array of widget nodes, enabling server-driven UI customization of the object detail page layout.
33+
34+
- **`pageSchemaFactory` on MetadataTypeConfig** (`@object-ui/console`): New optional factory function on the registry config that generates a `PageSchema` for detail page rendering via `SchemaRenderer`. When defined, `MetadataDetailPage` uses schema rendering instead of the default card layout.
35+
2036
- **Grid list mode for MetadataManagerPage** (`@object-ui/console`): MetadataManagerPage now supports `listMode: 'grid' | 'table'` configuration via the metadata type registry. When set, items are rendered in a professional table layout with column headers, sortable rows, and inline action buttons — matching the Power Apps table listing UX. The `report` type is configured to use grid mode by default.
2137

2238
- **Enhanced Object Detail View (Power Apps alignment)** (`@object-ui/console`): ObjectDetailView now includes dedicated sections beyond the existing Object Properties and Fields:
@@ -28,7 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2844

2945
- **Reusable MetadataGrid component** (`@object-ui/console`): Extracted the grid/table rendering logic from MetadataManagerPage into a standalone `MetadataGrid` component (`components/MetadataGrid.tsx`). Supports configurable columns, action buttons, row click handlers, and delete confirmation state. Can be reused by any metadata type list page.
3046

31-
- **MetadataDetailPage redirect for custom types** (`@object-ui/console`): MetadataDetailPage now automatically redirects to the dedicated detail page for metadata types with `hasCustomPage: true`. For example, navigating to `/system/metadata/object/account` redirects to `/system/objects/account`.
47+
- **MetadataDetailPage unified schema rendering** (`@object-ui/console`): MetadataDetailPage now renders object detail pages via PageSchema (using `pageSchemaFactory`) instead of redirecting to `/system/objects/:name`. The redirect-based approach (`hasCustomPage` + `<Navigate>`) has been replaced with direct schema rendering.
3248

3349
- **MetadataProvider dynamic type access** (`@object-ui/console`): Added `getItemsByType(type)` method to `MetadataContextValue`, allowing pages to access cached metadata items for any known type without hardcoding property names.
3450

apps/console/src/__tests__/MetadataDetailPage.test.tsx

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22
* MetadataDetailPage Tests
33
*
44
* Tests for the generic, registry-driven metadata detail page that shows
5-
* a single metadata item and supports editing via the MetadataFormDialog.
5+
* a single metadata item. Supports three rendering modes:
6+
* 1. PageSchema-driven (via pageSchemaFactory + SchemaRenderer)
7+
* 2. Custom component (via detailComponent)
8+
* 3. Default card layout
69
*/
710

811
import { describe, it, expect, vi, beforeEach } from 'vitest';
912
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
1013
import '@testing-library/jest-dom';
1114
import { MemoryRouter, Routes, Route } from 'react-router-dom';
15+
import { ComponentRegistry } from '@object-ui/core';
1216

1317
// --- Mock MetadataService ---
1418
const mockGetItems = vi.fn().mockResolvedValue([]);
@@ -25,7 +29,20 @@ const mockRefresh = vi.fn().mockResolvedValue(undefined);
2529
vi.mock('../context/MetadataProvider', () => ({
2630
useMetadata: () => ({
2731
apps: [],
28-
objects: [],
32+
objects: [
33+
{
34+
name: 'account',
35+
label: 'Accounts',
36+
icon: 'Building',
37+
description: 'Customer accounts',
38+
enabled: true,
39+
fields: [
40+
{ name: 'id', type: 'text', label: 'ID', readonly: true },
41+
{ name: 'name', type: 'text', label: 'Account Name', required: true },
42+
],
43+
relationships: [],
44+
},
45+
],
2946
dashboards: [],
3047
reports: [],
3148
pages: [],
@@ -52,6 +69,22 @@ vi.mock('react-router-dom', async () => {
5269
};
5370
});
5471

72+
// Register mock widget components for PageSchema rendering in tests
73+
beforeEach(() => {
74+
const mockWidget = (name: string) => (props: any) => (
75+
<div data-testid={`mock-${name}`} data-object-name={props?.schema?.objectName || props?.objectName}>
76+
{name}
77+
</div>
78+
);
79+
80+
ComponentRegistry.register('object-properties', mockWidget('object-properties'));
81+
ComponentRegistry.register('object-relationships', mockWidget('object-relationships'));
82+
ComponentRegistry.register('object-keys', mockWidget('object-keys'));
83+
ComponentRegistry.register('object-data-experience', mockWidget('object-data-experience'));
84+
ComponentRegistry.register('object-data-preview', mockWidget('object-data-preview'));
85+
ComponentRegistry.register('object-field-designer', mockWidget('object-field-designer'));
86+
});
87+
5588
// Import after mocks
5689
import { MetadataDetailPage } from '../pages/system/MetadataDetailPage';
5790

@@ -155,25 +188,66 @@ describe('MetadataDetailPage', () => {
155188
});
156189
});
157190

158-
describe('redirect for custom page types', () => {
159-
it('should redirect object type to /system/objects/:name', () => {
160-
const { container } = render(
191+
describe('PageSchema rendering for object type', () => {
192+
it('should render object detail via PageSchema instead of redirecting', () => {
193+
mockGetItems.mockResolvedValue([
194+
{ name: 'account', label: 'Accounts', description: 'Customer accounts' },
195+
]);
196+
render(
197+
<MemoryRouter initialEntries={['/system/metadata/object/account']}>
198+
<Routes>
199+
<Route
200+
path="/system/metadata/:metadataType/:itemName"
201+
element={<MetadataDetailPage />}
202+
/>
203+
</Routes>
204+
</MemoryRouter>,
205+
);
206+
// Should render the metadata detail page (not redirect)
207+
expect(screen.getByTestId('metadata-detail-page')).toBeInTheDocument();
208+
// Should render schema-driven content via mock widgets
209+
expect(screen.getByTestId('schema-detail-content')).toBeInTheDocument();
210+
});
211+
212+
it('should render all object detail widget sections', () => {
213+
mockGetItems.mockResolvedValue([
214+
{ name: 'account', label: 'Accounts', description: 'Customer accounts' },
215+
]);
216+
render(
161217
<MemoryRouter initialEntries={['/system/metadata/object/account']}>
162218
<Routes>
163219
<Route
164220
path="/system/metadata/:metadataType/:itemName"
165221
element={<MetadataDetailPage />}
166222
/>
223+
</Routes>
224+
</MemoryRouter>,
225+
);
226+
// All widget sections should be rendered via SchemaRenderer
227+
expect(screen.getByTestId('mock-object-properties')).toBeInTheDocument();
228+
expect(screen.getByTestId('mock-object-relationships')).toBeInTheDocument();
229+
expect(screen.getByTestId('mock-object-keys')).toBeInTheDocument();
230+
expect(screen.getByTestId('mock-object-data-experience')).toBeInTheDocument();
231+
expect(screen.getByTestId('mock-object-data-preview')).toBeInTheDocument();
232+
expect(screen.getByTestId('mock-object-field-designer')).toBeInTheDocument();
233+
});
234+
235+
it('should navigate back to object list route when back button is clicked', () => {
236+
mockGetItems.mockResolvedValue([
237+
{ name: 'account', label: 'Accounts', description: 'Customer accounts' },
238+
]);
239+
render(
240+
<MemoryRouter initialEntries={['/system/metadata/object/account']}>
241+
<Routes>
167242
<Route
168-
path="/system/objects/:objectName"
169-
element={<div data-testid="object-detail-redirect-target">Redirected</div>}
243+
path="/system/metadata/:metadataType/:itemName"
244+
element={<MetadataDetailPage />}
170245
/>
171246
</Routes>
172247
</MemoryRouter>,
173248
);
174-
// The Navigate component should redirect to the object detail route
175-
expect(screen.getByTestId('object-detail-redirect-target')).toBeInTheDocument();
176-
expect(screen.getByText('Redirected')).toBeInTheDocument();
249+
fireEvent.click(screen.getByTestId('back-to-list-btn'));
250+
expect(mockNavigate).toHaveBeenCalledWith('/system/objects');
177251
});
178252
});
179253
});

apps/console/src/__tests__/ObjectManagerPage.test.tsx

Lines changed: 29 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22
* ObjectManagerPage tests
33
*
44
* Tests for the system administration Object Manager page that integrates
5-
* ObjectManager and FieldDesigner from @object-ui/plugin-designer.
6-
* Covers list view, detail view with URL-based navigation, field management,
5+
* ObjectManager from @object-ui/plugin-designer for the list view, and
6+
* PageSchema-driven SchemaRenderer for the detail view.
7+
* Covers list view, detail view with URL-based navigation, schema rendering,
78
* and API integration via MetadataService.
89
*/
910

1011
import { describe, it, expect, vi, beforeEach } from 'vitest';
1112
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
1213
import { MemoryRouter, Routes, Route } from 'react-router-dom';
14+
import { ComponentRegistry } from '@object-ui/core';
1315
import { ObjectManagerPage } from '../pages/system/ObjectManagerPage';
1416

1517
// ---------------------------------------------------------------------------
@@ -102,6 +104,20 @@ function renderPage(route = '/system/objects') {
102104
describe('ObjectManagerPage', () => {
103105
beforeEach(() => {
104106
vi.clearAllMocks();
107+
108+
// Register mock widget components for PageSchema rendering in tests
109+
const mockWidget = (name: string) => (props: any) => (
110+
<div data-testid={name} data-object-name={props?.schema?.objectName || props?.objectName}>
111+
{name}
112+
</div>
113+
);
114+
115+
ComponentRegistry.register('object-properties', mockWidget('object-properties'));
116+
ComponentRegistry.register('object-relationships', mockWidget('relationships-section'));
117+
ComponentRegistry.register('object-keys', mockWidget('keys-section'));
118+
ComponentRegistry.register('object-data-experience', mockWidget('data-experience-section'));
119+
ComponentRegistry.register('object-data-preview', mockWidget('data-preview-section'));
120+
ComponentRegistry.register('object-field-designer', mockWidget('field-management-section'));
105121
});
106122

107123
describe('List View', () => {
@@ -140,17 +156,14 @@ describe('ObjectManagerPage', () => {
140156
expect(titles.length).toBeGreaterThanOrEqual(1);
141157
});
142158

143-
it('should show object properties section', () => {
159+
it('should show object properties section via schema widget', () => {
144160
renderPage('/system/objects/account');
145161
expect(screen.getByTestId('object-properties')).toBeDefined();
146-
expect(screen.getByText('API Name')).toBeDefined();
147-
expect(screen.getByText('account')).toBeDefined();
148162
});
149163

150-
it('should show field management section with FieldDesigner', () => {
164+
it('should show field management section via schema widget', () => {
151165
renderPage('/system/objects/account');
152166
expect(screen.getByTestId('field-management-section')).toBeDefined();
153-
expect(screen.getByTestId('field-designer')).toBeDefined();
154167
});
155168

156169
it('should show back button to return to object list', () => {
@@ -167,47 +180,29 @@ describe('ObjectManagerPage', () => {
167180
});
168181
});
169182

170-
it('should show relationships if the object has them', () => {
183+
it('should show relationships section via schema widget', () => {
171184
renderPage('/system/objects/account');
172-
expect(screen.getByText('Relationships')).toBeDefined();
173185
expect(screen.getByTestId('relationships-section')).toBeDefined();
174-
expect(screen.getByText('one-to-many')).toBeDefined();
175-
expect(screen.getByText(/contacts/)).toBeDefined();
176186
});
177187

178-
it('should show keys section', () => {
188+
it('should show keys section via schema widget', () => {
179189
renderPage('/system/objects/account');
180190
expect(screen.getByTestId('keys-section')).toBeDefined();
181-
expect(screen.getByText('Keys')).toBeDefined();
182191
});
183192

184-
it('should show data experience section with placeholders', () => {
193+
it('should show data experience section via schema widget', () => {
185194
renderPage('/system/objects/account');
186195
expect(screen.getByTestId('data-experience-section')).toBeDefined();
187-
expect(screen.getByText('Data Experience')).toBeDefined();
188-
expect(screen.getByTestId('data-experience-forms')).toBeDefined();
189-
expect(screen.getByTestId('data-experience-views')).toBeDefined();
190-
expect(screen.getByTestId('data-experience-dashboards')).toBeDefined();
191196
});
192197

193-
it('should show empty relationships message for objects without them', () => {
194-
renderPage('/system/objects/contact');
195-
expect(screen.getByTestId('relationships-section')).toBeDefined();
196-
expect(screen.getByText('No relationships defined for this object.')).toBeDefined();
197-
});
198-
199-
it('should show inline data preview placeholder', () => {
198+
it('should show data preview section via schema widget', () => {
200199
renderPage('/system/objects/account');
201200
expect(screen.getByTestId('data-preview-section')).toBeDefined();
202-
expect(screen.getByText('Data Preview')).toBeDefined();
203-
expect(screen.getByText('Sample Data')).toBeDefined();
204201
});
205202

206-
it('should show system field non-editable hint when system fields exist', () => {
203+
it('should render schema-driven content container', () => {
207204
renderPage('/system/objects/account');
208-
// account has id field with readonly=true, which becomes isSystem
209-
expect(screen.getByTestId('system-field-hint')).toBeDefined();
210-
expect(screen.getByText(/System fields.*read-only/)).toBeDefined();
205+
expect(screen.getByTestId('schema-detail-content')).toBeDefined();
211206
});
212207
});
213208

@@ -245,11 +240,11 @@ describe('ObjectManagerPage', () => {
245240
expect(screen.getByTestId('object-manager-page')).toBeDefined();
246241
});
247242

248-
it('should render detail view with MetadataService props', () => {
243+
it('should render detail view with schema-driven widgets', () => {
249244
renderPage('/system/objects/account');
250245
expect(screen.getByTestId('object-detail-view')).toBeDefined();
251-
// The FieldDesigner should be rendered (service is passed through)
252-
expect(screen.getByTestId('field-designer')).toBeDefined();
246+
// Schema widgets are rendered (field designer as a registered widget type)
247+
expect(screen.getByTestId('field-management-section')).toBeDefined();
253248
});
254249
});
255250

apps/console/src/__tests__/SystemPages.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ describe('SystemHubPage', () => {
217217
expect(mockNavigate).toHaveBeenCalledWith('/apps/test-app/system/metadata/dashboard');
218218
});
219219

220-
it('should navigate to custom route for types with custom pages', async () => {
220+
it('should navigate to custom route for types with dedicated list pages', async () => {
221221
mockFind.mockResolvedValue({ data: [] });
222222
wrap(<SystemHubPage />);
223223
await waitFor(() => {

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,22 +35,26 @@ describe('metadataTypeRegistry', () => {
3535
}
3636
});
3737

38-
it('should mark app and object as having custom pages', () => {
38+
it('should configure app and object with custom routes', () => {
3939
const app = METADATA_TYPES.find((m) => m.type === 'app')!;
4040
const obj = METADATA_TYPES.find((m) => m.type === 'object')!;
41-
expect(app.hasCustomPage).toBe(true);
4241
expect(app.customRoute).toBe('/system/apps');
43-
expect(obj.hasCustomPage).toBe(true);
4442
expect(obj.customRoute).toBe('/system/objects');
4543
});
4644

47-
it('should not mark generic types as having custom pages', () => {
45+
it('should configure object with pageSchemaFactory', () => {
46+
const obj = METADATA_TYPES.find((m) => m.type === 'object')!;
47+
expect(obj.pageSchemaFactory).toBeDefined();
48+
expect(typeof obj.pageSchemaFactory).toBe('function');
49+
});
50+
51+
it('should not configure generic types with custom routes', () => {
4852
const dashboard = METADATA_TYPES.find((m) => m.type === 'dashboard')!;
4953
const page = METADATA_TYPES.find((m) => m.type === 'page')!;
5054
const report = METADATA_TYPES.find((m) => m.type === 'report')!;
51-
expect(dashboard.hasCustomPage).toBeFalsy();
52-
expect(page.hasCustomPage).toBeFalsy();
53-
expect(report.hasCustomPage).toBeFalsy();
55+
expect(dashboard.customRoute).toBeFalsy();
56+
expect(page.customRoute).toBeFalsy();
57+
expect(report.customRoute).toBeFalsy();
5458
});
5559

5660
it('should have unique type strings', () => {

0 commit comments

Comments
 (0)