Skip to content
Merged
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

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

- **Enhanced Object Detail View (Power Apps alignment)** (`@object-ui/console`): ObjectDetailView now includes dedicated sections beyond the existing Object Properties and Fields:
- **Relationships section**: Displays all relationships with type badges (one-to-many, many-to-one), related object names, and foreign key info. Shows empty state message when no relationships are defined.
- **Keys section**: Automatically extracts and displays primary keys, unique keys, and external ID fields from the object's field metadata.
- **Data Experience section**: Placeholder cards for Forms, Views, and Dashboards design capabilities — preparing the UI structure for future implementation.
- **Inline data preview placeholder**: Dedicated "Data Preview" section with placeholder UI for sample data grid (Power Apps parity).
- **System field hints**: Visual indicator warning that system fields (id, createdAt, updatedAt) are read-only and cannot be edited.

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

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

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

- **Number and boolean field types in MetadataFormDialog** (`@object-ui/console`): Extended `MetadataFormFieldDef.type` to support `'number'` (renders HTML number input) and `'boolean'` (renders a Shadcn Switch toggle with Yes/No label). All existing field types (`text`, `textarea`, `select`) continue to work unchanged.

- **Shared metadata converters utility** (`@object-ui/console`): Extracted `toObjectDefinition()` and `toFieldDefinition()` from ObjectManagerPage into a shared `utils/metadataConverters.ts` module. These functions convert raw ObjectStack API metadata shapes to the UI types (`ObjectDefinition`, `DesignerFieldDefinition`), and can now be reused across pages.

- **Unified icon resolver** (`@object-ui/console`): Consolidated three duplicated `ICON_MAP` + `resolveIcon()` implementations (in MetadataManagerPage, MetadataDetailPage, SystemHubPage) into a single `getIcon()` utility from `utils/getIcon.ts`, which dynamically resolves any Lucide icon by kebab-case or PascalCase name.

- **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.
Expand Down
28 changes: 27 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -908,7 +908,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind

### P1.16 Object Manager & Field Designer ✅

> **Status:** Complete — `ObjectManager` and `FieldDesigner` components shipped in `@object-ui/plugin-designer`.
> **Status:** Complete — `ObjectManager` and `FieldDesigner` components shipped in `@object-ui/plugin-designer`. Object Detail View enhanced with Power Apps-style sections.

Enterprise-grade visual designers for managing object definitions and configuring fields. Supports the full metadata platform workflow: define objects, configure fields with advanced properties, and maintain relationships.

Expand All @@ -924,6 +924,32 @@ Enterprise-grade visual designers for managing object definitions and configurin
- [x] Confirm dialog for destructive actions
- [x] 18 unit tests

**Object Detail View (Power Apps alignment):**
- [x] Dedicated Relationships section with type badges, foreign key info, and empty state
- [x] Keys section auto-extracting primary keys, unique keys, and external IDs from field metadata
- [x] Data Experience placeholder section (Forms, Views, Dashboards) — UI structure ready for future implementation
- [x] Inline data preview placeholder section (Power Apps sample data grid parity)
- [x] System field non-editable visual hints
- [x] Enhanced object properties card with separated concern sections

**Metadata Manager Grid Mode:**
- [x] `listMode: 'grid' | 'table' | 'card'` configuration on MetadataTypeConfig
- [x] Professional table rendering with column headers and action buttons in grid mode
- [x] Report type configured with grid mode by default
- [x] Reusable `MetadataGrid` component extracted for cross-page reuse

**MetadataFormDialog Enhancements:**
- [x] `number` field type — renders HTML number input
- [x] `boolean` field type — renders Shadcn Switch toggle with Yes/No label

**MetadataDetailPage & Provider Enhancements:**
- [x] Auto-redirect for custom page types (object → `/system/objects/:name`)
- [x] `getItemsByType(type)` method on MetadataProvider for dynamic registry access

**Technical Debt Cleanup:**
- [x] Unified icon resolver — consolidated 3 duplicated ICON_MAP/resolveIcon into shared `getIcon` utility
- [x] Extracted `toObjectDefinition`/`toFieldDefinition` to shared `utils/metadataConverters.ts`

**Field Designer (`FieldDesigner`):**
- [x] CRUD operations on field definitions with 27 supported field types
- [x] Advanced field properties: uniqueness, default values, options/picklists, read-only, hidden, validation rules, external ID, history tracking, indexed
Expand Down
22 changes: 22 additions & 0 deletions apps/console/src/__tests__/MetadataDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,26 @@ describe('MetadataDetailPage', () => {
expect(screen.getByTestId('detail-loading')).toBeInTheDocument();
});
});

describe('redirect for custom page types', () => {
it('should redirect object type to /system/objects/:name', () => {
const { container } = render(
<MemoryRouter initialEntries={['/system/metadata/object/account']}>
<Routes>
<Route
path="/system/metadata/:metadataType/:itemName"
element={<MetadataDetailPage />}
/>
<Route
path="/system/objects/:objectName"
element={<div data-testid="object-detail-redirect-target">Redirected</div>}
/>
</Routes>
</MemoryRouter>,
);
// The Navigate component should redirect to the object detail route
expect(screen.getByTestId('object-detail-redirect-target')).toBeInTheDocument();
expect(screen.getByText('Redirected')).toBeInTheDocument();
});
});
});
68 changes: 68 additions & 0 deletions apps/console/src/__tests__/MetadataFormDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,74 @@ describe('MetadataFormDialog', () => {
});
});

describe('number fields', () => {
it('should render number input for fields with type number', () => {
const formFields = [
{ key: 'name', label: 'Name', required: true },
{ key: 'priority', label: 'Priority', type: 'number' as const },
];
render(<MetadataFormDialog {...defaultProps} formFields={formFields} />);
const priorityField = screen.getByTestId('metadata-field-priority');
expect(priorityField).toBeInTheDocument();
expect(priorityField).toHaveAttribute('type', 'number');
});

it('should accept numeric values', () => {
const formFields = [
{ key: 'name', label: 'Name', required: true },
{ key: 'count', label: 'Count', type: 'number' as const },
];
render(<MetadataFormDialog {...defaultProps} formFields={formFields} />);
fireEvent.change(screen.getByTestId('metadata-field-count'), {
target: { value: '42' },
});
expect(screen.getByTestId('metadata-field-count')).toHaveValue(42);
});
});

describe('boolean fields', () => {
it('should render switch for fields with type boolean', () => {
const formFields = [
{ key: 'name', label: 'Name', required: true },
{ key: 'enabled', label: 'Enabled', type: 'boolean' as const },
];
render(<MetadataFormDialog {...defaultProps} formFields={formFields} />);
const enabledField = screen.getByTestId('metadata-field-enabled');
expect(enabledField).toBeInTheDocument();
expect(enabledField).toHaveAttribute('role', 'switch');
});

it('should toggle boolean value when clicked', () => {
const formFields = [
{ key: 'name', label: 'Name', required: true },
{ key: 'active', label: 'Active', type: 'boolean' as const },
];
render(<MetadataFormDialog {...defaultProps} formFields={formFields} />);
const switchEl = screen.getByTestId('metadata-field-active');
// Initially false ("No")
expect(screen.getByText('No')).toBeInTheDocument();
// Toggle
fireEvent.click(switchEl);
expect(screen.getByText('Yes')).toBeInTheDocument();
});

it('should pre-fill boolean true value in edit mode', () => {
const formFields = [
{ key: 'name', label: 'Name', required: true },
{ key: 'enabled', label: 'Enabled', type: 'boolean' as const },
];
render(
<MetadataFormDialog
{...defaultProps}
mode="edit"
formFields={formFields}
initialValues={{ name: 'test', enabled: true }}
/>,
);
expect(screen.getByText('Yes')).toBeInTheDocument();
});
});

describe('when dialog is closed', () => {
it('should not render dialog content when open is false', () => {
render(<MetadataFormDialog {...defaultProps} open={false} />);
Expand Down
60 changes: 60 additions & 0 deletions apps/console/src/__tests__/MetadataManagerPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,66 @@ describe('MetadataManagerPage', () => {
});
});

describe('grid list mode (report type)', () => {
it('should render grid/table layout for types with listMode grid', async () => {
mockGetItems.mockResolvedValue([
{ name: 'sales_report', label: 'Sales Report', description: 'Q1 Sales' },
{ name: 'ops_report', label: 'Ops Report', description: 'Ops data' },
]);
renderWithRoute('report');
await waitFor(() => {
expect(screen.getByTestId('metadata-grid')).toBeInTheDocument();
});
// Should render items in the grid
expect(screen.getByTestId('metadata-item-sales_report')).toBeInTheDocument();
expect(screen.getByTestId('metadata-item-ops_report')).toBeInTheDocument();
});

it('should render column headers in grid mode', async () => {
mockGetItems.mockResolvedValue([
{ name: 'test_report', label: 'Test Report', description: 'Test desc' },
]);
renderWithRoute('report');
await waitFor(() => {
expect(screen.getByTestId('metadata-grid')).toBeInTheDocument();
});
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Label')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
});

it('should render edit/delete buttons in grid rows', async () => {
mockGetItems.mockResolvedValue([
{ name: 'test_report', label: 'Test Report' },
]);
renderWithRoute('report');
await waitFor(() => {
expect(screen.getByTestId('edit-test_report-btn')).toBeInTheDocument();
expect(screen.getByTestId('delete-test_report-btn')).toBeInTheDocument();
});
});

it('should navigate to detail page when grid row is clicked', async () => {
mockGetItems.mockResolvedValue([
{ name: 'sales_report', label: 'Sales Report' },
]);
renderWithRoute('report');
await waitFor(() => {
expect(screen.getByTestId('metadata-item-sales_report')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('metadata-item-sales_report'));
expect(mockNavigate).toHaveBeenCalledWith('/system/metadata/report/sales_report');
});

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

describe('permission integration', () => {
it('should show create/edit/delete buttons for admin users (mocked as admin)', async () => {
mockGetItems.mockResolvedValue([
Expand Down
67 changes: 66 additions & 1 deletion apps/console/src/__tests__/ObjectManagerPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,44 @@ describe('ObjectManagerPage', () => {
it('should show relationships if the object has them', () => {
renderPage('/system/objects/account');
expect(screen.getByText('Relationships')).toBeDefined();
expect(screen.getByText(/contact.*one-to-many/)).toBeDefined();
expect(screen.getByTestId('relationships-section')).toBeDefined();
expect(screen.getByText('one-to-many')).toBeDefined();
expect(screen.getByText(/contacts/)).toBeDefined();
});

it('should show keys section', () => {
renderPage('/system/objects/account');
expect(screen.getByTestId('keys-section')).toBeDefined();
expect(screen.getByText('Keys')).toBeDefined();
});

it('should show data experience section with placeholders', () => {
renderPage('/system/objects/account');
expect(screen.getByTestId('data-experience-section')).toBeDefined();
expect(screen.getByText('Data Experience')).toBeDefined();
expect(screen.getByTestId('data-experience-forms')).toBeDefined();
expect(screen.getByTestId('data-experience-views')).toBeDefined();
expect(screen.getByTestId('data-experience-dashboards')).toBeDefined();
});

it('should show empty relationships message for objects without them', () => {
renderPage('/system/objects/contact');
expect(screen.getByTestId('relationships-section')).toBeDefined();
expect(screen.getByText('No relationships defined for this object.')).toBeDefined();
});

it('should show inline data preview placeholder', () => {
renderPage('/system/objects/account');
expect(screen.getByTestId('data-preview-section')).toBeDefined();
expect(screen.getByText('Data Preview')).toBeDefined();
expect(screen.getByText('Sample Data')).toBeDefined();
});

it('should show system field non-editable hint when system fields exist', () => {
renderPage('/system/objects/account');
// account has id field with readonly=true, which becomes isSystem
expect(screen.getByTestId('system-field-hint')).toBeDefined();
expect(screen.getByText(/System fields.*read-only/)).toBeDefined();
});
});

Expand Down Expand Up @@ -215,4 +252,32 @@ describe('ObjectManagerPage', () => {
expect(screen.getByTestId('field-designer')).toBeDefined();
});
});

// -------------------------------------------------------------------------
// Route compatibility regression tests
// -------------------------------------------------------------------------

describe('Route compatibility', () => {
it('should render list view at /system/objects', () => {
renderPage('/system/objects');
expect(screen.getByTestId('object-manager-page')).toBeDefined();
expect(screen.getByTestId('object-manager')).toBeDefined();
});

it('should render detail view at /system/objects/:objectName', () => {
renderPage('/system/objects/account');
expect(screen.getByTestId('object-detail-view')).toBeDefined();
});

it('should render detail view at /apps/:appName/system/objects/:objectName', () => {
renderPage('/apps/my_app/system/objects/account');
expect(screen.getByTestId('object-detail-view')).toBeDefined();
});

it('should render list view at /apps/:appName/system/objects', () => {
renderPage('/apps/my_app/system/objects');
expect(screen.getByTestId('object-manager-page')).toBeDefined();
expect(screen.getByTestId('object-manager')).toBeDefined();
});
});
});
Loading
Loading