diff --git a/CHANGELOG.md b/CHANGELOG.md index 77338a51d..c51f6994e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 2d44871e8..2700d2141 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. @@ -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 diff --git a/apps/console/src/__tests__/MetadataDetailPage.test.tsx b/apps/console/src/__tests__/MetadataDetailPage.test.tsx index 18f49b23f..50e4b692b 100644 --- a/apps/console/src/__tests__/MetadataDetailPage.test.tsx +++ b/apps/console/src/__tests__/MetadataDetailPage.test.tsx @@ -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( + + + } + /> + Redirected} + /> + + , + ); + // The Navigate component should redirect to the object detail route + expect(screen.getByTestId('object-detail-redirect-target')).toBeInTheDocument(); + expect(screen.getByText('Redirected')).toBeInTheDocument(); + }); + }); }); diff --git a/apps/console/src/__tests__/MetadataFormDialog.test.tsx b/apps/console/src/__tests__/MetadataFormDialog.test.tsx index 263e9767c..1a3795a39 100644 --- a/apps/console/src/__tests__/MetadataFormDialog.test.tsx +++ b/apps/console/src/__tests__/MetadataFormDialog.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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( + , + ); + expect(screen.getByText('Yes')).toBeInTheDocument(); + }); + }); + describe('when dialog is closed', () => { it('should not render dialog content when open is false', () => { render(); diff --git a/apps/console/src/__tests__/MetadataManagerPage.test.tsx b/apps/console/src/__tests__/MetadataManagerPage.test.tsx index 751482484..449cb07e2 100644 --- a/apps/console/src/__tests__/MetadataManagerPage.test.tsx +++ b/apps/console/src/__tests__/MetadataManagerPage.test.tsx @@ -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([ diff --git a/apps/console/src/__tests__/ObjectManagerPage.test.tsx b/apps/console/src/__tests__/ObjectManagerPage.test.tsx index 5e8cdff55..16f7714f1 100644 --- a/apps/console/src/__tests__/ObjectManagerPage.test.tsx +++ b/apps/console/src/__tests__/ObjectManagerPage.test.tsx @@ -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(); }); }); @@ -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(); + }); + }); }); diff --git a/apps/console/src/__tests__/metadataConverters.test.ts b/apps/console/src/__tests__/metadataConverters.test.ts new file mode 100644 index 000000000..4675d16bd --- /dev/null +++ b/apps/console/src/__tests__/metadataConverters.test.ts @@ -0,0 +1,174 @@ +/** + * Metadata Converters Tests + * + * Tests for the shared toObjectDefinition and toFieldDefinition converters. + */ + +import { describe, it, expect } from 'vitest'; +import { + toObjectDefinition, + toFieldDefinition, + type MetadataObject, + type MetadataField, +} from '../utils/metadataConverters'; + +describe('metadataConverters', () => { + describe('toObjectDefinition', () => { + it('should convert basic object definition', () => { + const obj: MetadataObject = { + name: 'account', + label: 'Account', + description: 'Customer accounts', + enabled: true, + fields: [ + { name: 'id', type: 'text' }, + { name: 'name', type: 'text' }, + ], + }; + + const result = toObjectDefinition(obj, 0); + expect(result.id).toBe('account'); + expect(result.name).toBe('account'); + expect(result.label).toBe('Account'); + expect(result.description).toBe('Customer accounts'); + expect(result.enabled).toBe(true); + expect(result.fieldCount).toBe(2); + expect(result.group).toBe('Custom Objects'); + expect(result.isSystem).toBe(false); + }); + + it('should detect system objects by sys_ prefix', () => { + const obj: MetadataObject = { + name: 'sys_user', + label: 'User', + }; + const result = toObjectDefinition(obj, 0); + expect(result.isSystem).toBe(true); + expect(result.group).toBe('System Objects'); + }); + + it('should handle object label as object with defaultValue', () => { + const obj: MetadataObject = { + name: 'test', + label: { defaultValue: 'Test Label' }, + }; + const result = toObjectDefinition(obj, 0); + expect(result.label).toBe('Test Label'); + }); + + it('should handle pluralLabel with snake_case fallback', () => { + const obj: MetadataObject = { + name: 'test', + label: 'Test', + plural_label: 'Tests', + }; + const result = toObjectDefinition(obj, 0); + expect(result.pluralLabel).toBe('Tests'); + }); + + it('should convert relationships', () => { + const obj: MetadataObject = { + name: 'account', + label: 'Account', + relationships: [ + { object: 'contact', type: 'one-to-many', name: 'contacts' }, + ], + }; + const result = toObjectDefinition(obj, 0); + expect(result.relationships).toHaveLength(1); + expect(result.relationships![0].relatedObject).toBe('contact'); + expect(result.relationships![0].type).toBe('one-to-many'); + }); + + it('should handle fields as Record (object format)', () => { + const obj: MetadataObject = { + name: 'account', + label: 'Account', + fields: { + id: { name: 'id', type: 'text' }, + name: { name: 'name', type: 'text' }, + email: { name: 'email', type: 'email' }, + }, + }; + const result = toObjectDefinition(obj, 0); + expect(result.fieldCount).toBe(3); + }); + }); + + describe('toFieldDefinition', () => { + it('should convert basic field definition', () => { + const field: MetadataField = { + name: 'email', + label: 'Email', + type: 'email', + required: true, + }; + const result = toFieldDefinition(field, 0); + expect(result.id).toBe('email'); + expect(result.name).toBe('email'); + expect(result.label).toBe('Email'); + expect(result.type).toBe('email'); + expect(result.required).toBe(true); + }); + + it('should detect system fields (id, createdAt, updatedAt)', () => { + const field: MetadataField = { + name: 'id', + label: 'ID', + type: 'text', + readonly: true, + }; + const result = toFieldDefinition(field, 0); + expect(result.isSystem).toBe(true); + }); + + it('should handle default_value snake_case fallback', () => { + const field: MetadataField = { + name: 'status', + type: 'text', + default_value: 'active', + }; + const result = toFieldDefinition(field, 0); + expect(result.defaultValue).toBe('active'); + }); + + it('should convert string options to label/value pairs', () => { + const field: MetadataField = { + name: 'status', + type: 'select', + options: ['active', 'inactive'], + }; + const result = toFieldDefinition(field, 0); + expect(result.options).toEqual([ + { label: 'active', value: 'active' }, + { label: 'inactive', value: 'inactive' }, + ]); + }); + + it('should handle object options with color', () => { + const field: MetadataField = { + name: 'priority', + type: 'select', + options: [ + { label: 'High', value: 'high', color: 'red' }, + { value: 'low' }, + ], + }; + const result = toFieldDefinition(field, 0); + expect(result.options).toEqual([ + { label: 'High', value: 'high', color: 'red' }, + { label: 'low', value: 'low', color: undefined }, + ]); + }); + + it('should handle reference_to snake_case fallback', () => { + const field: MetadataField = { + name: 'account_id', + type: 'lookup', + reference_to: 'account', + }; + const result = toFieldDefinition(field, 0); + expect(result.referenceTo).toBe('account'); + }); + }); +}); diff --git a/apps/console/src/components/MetadataFormDialog.tsx b/apps/console/src/components/MetadataFormDialog.tsx index 293267a64..85b5d7165 100644 --- a/apps/console/src/components/MetadataFormDialog.tsx +++ b/apps/console/src/components/MetadataFormDialog.tsx @@ -21,6 +21,7 @@ import { Input, Label, Textarea, + Switch, } from '@object-ui/components'; import { Loader2 } from 'lucide-react'; import { @@ -65,7 +66,8 @@ export function MetadataFormDialog({ const fields = formFields ?? DEFAULT_FORM_FIELDS; const isEdit = mode === 'edit'; - // Form state + // Form state — values are stored as strings because the form is a generic + // key-value store. Boolean fields use 'true'/'false' string representations. const [values, setValues] = useState>({}); const [submitting, setSubmitting] = useState(false); @@ -157,6 +159,33 @@ export function MetadataFormDialog({ ))} + ) : field.type === 'number' ? ( + ) => + handleChange(field.key, e.target.value) + } + placeholder={field.placeholder} + disabled={disabled} + data-testid={`metadata-field-${field.key}`} + /> + ) : field.type === 'boolean' ? ( +
+ + handleChange(field.key, String(checked)) + } + disabled={disabled} + data-testid={`metadata-field-${field.key}`} + /> + +
) : ( []; + /** Column definitions for the table headers. */ + columns: MetadataColumnDef[]; + /** Whether the grid shows action buttons (edit, delete, custom). */ + editable?: boolean; + /** Whether a save operation is in progress (disables action buttons). */ + saving?: boolean; + /** Name of the item currently in the delete-confirm state. */ + deletingName?: string | null; + /** Singular type label for tooltip text (e.g. 'report'). */ + typeLabel?: string; + /** Custom row-level actions from the registry. */ + rowActions?: MetadataActionDef[]; + /** Called when an item row is clicked. */ + onItemClick?: (name: string) => void; + /** Called when the edit button is clicked for an item. */ + onEdit?: (item: Record) => void; + /** Called when the delete button is clicked for an item. */ + onDelete?: (name: string) => void; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function MetadataGrid({ + items, + columns, + editable = false, + saving = false, + deletingName = null, + typeLabel = 'item', + rowActions = [], + onItemClick, + onEdit, + onDelete, +}: MetadataGridProps) { + return ( +
+
+ + + + {columns.map((col) => ( + + ))} + {editable && ( + + )} + + + + {items.map((item) => { + const name = String(item.name ?? ''); + return ( + onItemClick?.(name)} + > + {columns.map((col) => ( + + ))} + {editable && ( + + )} + + ); + })} + +
+ {col.label} + + Actions +
+ {String(item[col.key] ?? '—')} + +
+ {rowActions.map((action) => ( + + ))} + + +
+
+
+
+ ); +} diff --git a/apps/console/src/config/metadataTypeRegistry.ts b/apps/console/src/config/metadataTypeRegistry.ts index 834346586..78ccc09df 100644 --- a/apps/console/src/config/metadataTypeRegistry.ts +++ b/apps/console/src/config/metadataTypeRegistry.ts @@ -41,7 +41,7 @@ export interface MetadataFormFieldDef { /** Human-readable label shown next to the input. */ label: string; /** Input type. Defaults to `'text'`. */ - type?: 'text' | 'textarea' | 'select'; + type?: 'text' | 'textarea' | 'select' | 'number' | 'boolean'; /** Placeholder text for the input. */ placeholder?: string; /** Whether the field is required. Defaults to `false`. */ @@ -153,6 +153,14 @@ export interface MetadataTypeConfig { * appear alongside each item's edit/delete buttons. */ actions?: MetadataActionDef[]; + + /** + * Display mode for the list view. + * - `'card'` (default): Responsive card grid layout. + * - `'grid'`: Professional table/grid layout with column headers. + * - `'table'`: Alias for `'grid'` (same rendering). + */ + listMode?: 'card' | 'grid' | 'table'; } // --------------------------------------------------------------------------- @@ -230,6 +238,7 @@ export const METADATA_TYPES: MetadataTypeConfig[] = [ pluralLabel: 'Reports', description: 'Manage report configurations and templates', icon: 'bar-chart-3', + listMode: 'grid', columns: [ { key: 'name', label: 'Name' }, { key: 'label', label: 'Label' }, diff --git a/apps/console/src/context/MetadataProvider.tsx b/apps/console/src/context/MetadataProvider.tsx index a78b874e5..f215e3169 100644 --- a/apps/console/src/context/MetadataProvider.tsx +++ b/apps/console/src/context/MetadataProvider.tsx @@ -27,6 +27,13 @@ export interface MetadataState { export interface MetadataContextValue extends MetadataState { /** Re-fetch all metadata from the API (cache invalidation). */ refresh: () => Promise; + /** + * Fetch items for any metadata type dynamically (registry-driven). + * Returns cached items for known types (app, object, dashboard, report, page). + * Uses `any[]` to match the MetadataState field types — consumers should + * cast to their expected item shape as needed. + */ + getItemsByType: (type: string) => any[]; } const MetadataCtx = createContext(null); @@ -116,7 +123,18 @@ export function MetadataProvider({ children, adapter }: MetadataProviderProps) { }; }, [adapter]); - const value: MetadataContextValue = { ...state, refresh }; + const getItemsByType = useCallback((type: string): any[] => { + const typeMap: Record = { + app: state.apps, + object: state.objects, + dashboard: state.dashboards, + report: state.reports, + page: state.pages, + }; + return typeMap[type] ?? []; + }, [state.apps, state.objects, state.dashboards, state.reports, state.pages]); + + const value: MetadataContextValue = { ...state, refresh, getItemsByType }; return {children}; } diff --git a/apps/console/src/pages/system/MetadataDetailPage.tsx b/apps/console/src/pages/system/MetadataDetailPage.tsx index f0956d976..b0b37075c 100644 --- a/apps/console/src/pages/system/MetadataDetailPage.tsx +++ b/apps/console/src/pages/system/MetadataDetailPage.tsx @@ -11,7 +11,7 @@ */ import { useState, useCallback, useEffect } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, Navigate } from 'react-router-dom'; import { Button, Card, @@ -24,11 +24,6 @@ import { ArrowLeft, Pencil, Loader2, - LayoutDashboard, - FileText, - BarChart3, - Database, - LayoutGrid, } from 'lucide-react'; import { toast } from 'sonner'; import { useAuth } from '@object-ui/auth'; @@ -36,22 +31,7 @@ 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; -} +import { getIcon } from '../../utils/getIcon'; // --------------------------------------------------------------------------- // Component @@ -76,6 +56,9 @@ export function MetadataDetailPage() { ? getMetadataTypeConfig(metadataType) : undefined; + // Redirect to dedicated detail page for types with custom pages (e.g. object → /system/objects/:name) + const shouldRedirect = config?.hasCustomPage && config.customRoute && itemName; + const [item, setItem] = useState | null>(null); const [loading, setLoading] = useState(true); const [editOpen, setEditOpen] = useState(false); @@ -139,7 +122,12 @@ export function MetadataDetailPage() { ); } - const Icon = resolveIcon(config.icon); + // Declarative redirect for types with custom pages (e.g. object → /system/objects/:name) + if (shouldRedirect) { + return ; + } + + const Icon = getIcon(config.icon); 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 4ad142454..28d25ce5d 100644 --- a/apps/console/src/pages/system/MetadataManagerPage.tsx +++ b/apps/console/src/pages/system/MetadataManagerPage.tsx @@ -31,11 +31,6 @@ import { Trash2, Search, Loader2, - LayoutDashboard, - FileText, - BarChart3, - Database, - LayoutGrid, } from 'lucide-react'; import { toast } from 'sonner'; import { useAuth } from '@object-ui/auth'; @@ -43,22 +38,8 @@ import { useMetadataService } from '../../hooks/useMetadataService'; import { useMetadata } from '../../context/MetadataProvider'; import { getMetadataTypeConfig, type MetadataTypeConfig } from '../../config/metadataTypeRegistry'; import { MetadataFormDialog } from '../../components/MetadataFormDialog'; - -// --------------------------------------------------------------------------- -// Icon resolver -// --------------------------------------------------------------------------- - -const ICON_MAP: Record> = { - 'layout-dashboard': LayoutDashboard, - 'file-text': FileText, - 'bar-chart-3': BarChart3, - 'database': Database, - 'layout-grid': LayoutGrid, -}; - -function resolveIcon(iconName: string): React.ComponentType<{ className?: string }> { - return ICON_MAP[iconName] ?? Database; -} +import { MetadataGrid } from '../../components/MetadataGrid'; +import { getIcon } from '../../utils/getIcon'; // --------------------------------------------------------------------------- // Component @@ -203,10 +184,16 @@ export function MetadataManagerPage() { ); } - const Icon = resolveIcon(config.icon); + const Icon = getIcon(config.icon); const isEditable = config.editable !== false && isAdmin; const pageActions = (config.actions ?? []).filter((a) => a.scope === 'page'); const rowActions = (config.actions ?? []).filter((a) => a.scope === 'row'); + const listMode = config.listMode ?? 'card'; + const isGridMode = listMode === 'grid' || listMode === 'table'; + const columns = config.columns ?? [ + { key: 'name', label: 'Name' }, + { key: 'label', label: 'Label' }, + ]; return (
@@ -296,7 +283,7 @@ export function MetadataManagerPage() {
)} - {!loading && filteredItems.length > 0 && ( + {!loading && filteredItems.length > 0 && !isGridMode && (
{filteredItems.map((item) => { const name = String(item.name ?? ''); @@ -371,6 +358,24 @@ export function MetadataManagerPage() {
)} + {/* Grid / Table mode */} + {!loading && filteredItems.length > 0 && isGridMode && ( + + navigate(`${basePath}/system/metadata/${metadataType}/${name}`) + } + onEdit={handleEdit} + onDelete={handleDelete} + /> + )} + {/* Create/Edit dialog */} {isEditable && ( ; - relationships?: Array<{ - object?: string; - relatedObject?: string; - type?: string; - label?: string; - name?: string; - foreign_key?: string; - foreignKey?: string; - }>; -} - -/** Loose shape of a metadata field definition from the ObjectStack API. */ -interface MetadataField { - name?: string; - label?: string | { defaultValue?: string; key?: string }; - type?: string; - group?: string; - description?: string; - help?: string; - required?: boolean; - unique?: boolean; - readonly?: boolean; - hidden?: boolean; - defaultValue?: string; - default_value?: string; - placeholder?: string; - options?: Array; - externalId?: boolean; - trackHistory?: boolean; - track_history?: boolean; - indexed?: boolean; - reference_to?: string; - referenceTo?: string; - formula?: string; -} - -/** - * Convert a metadata object definition (from the API/spec) to the ObjectDefinition - * type used by the ObjectManager component. - */ -function toObjectDefinition(obj: MetadataObject, index: number): ObjectDefinition { - const fields = Array.isArray(obj.fields) ? obj.fields : Object.values(obj.fields || {}); - return { - id: obj.name || `obj_${index}`, - name: obj.name || '', - label: typeof obj.label === 'object' ? obj.label.defaultValue || obj.label.key || '' : (obj.label || obj.name || ''), - pluralLabel: obj.pluralLabel || obj.plural_label || undefined, - description: typeof obj.description === 'object' ? obj.description.defaultValue : (obj.description || undefined), - icon: obj.icon || undefined, - group: obj.name?.startsWith('sys_') ? 'System Objects' : 'Custom Objects', - sortOrder: index, - isSystem: obj.name?.startsWith('sys_') || false, - enabled: obj.enabled !== false, - fieldCount: fields.length, - relationships: Array.isArray(obj.relationships) - ? obj.relationships.map((r: any) => ({ - relatedObject: r.object || r.relatedObject || '', - type: r.type || 'one-to-many', - label: r.label || r.name || undefined, - foreignKey: r.foreign_key || r.foreignKey || undefined, - })) - : undefined, - }; -} - -/** - * Convert a metadata field definition to the DesignerFieldDefinition - * type used by the FieldDesigner component. - */ -function toFieldDefinition(field: MetadataField, index: number): DesignerFieldDefinition { - return { - id: field.name || `fld_${index}`, - name: field.name || '', - label: typeof field.label === 'object' ? field.label.defaultValue || field.label.key || '' : (field.label || field.name || ''), - type: (field.type || 'text') as DesignerFieldType, - group: field.group || undefined, - sortOrder: index, - description: field.description || field.help || undefined, - required: field.required || false, - unique: field.unique || false, - readonly: field.readonly || false, - hidden: field.hidden || false, - defaultValue: field.defaultValue || field.default_value || undefined, - placeholder: field.placeholder || undefined, - options: Array.isArray(field.options) - ? field.options.map((opt) => - typeof opt === 'string' - ? { label: opt, value: opt } - : { label: opt.label || opt.value, value: opt.value, color: opt.color } - ) - : undefined, - isSystem: field.readonly === true && (field.name === 'id' || field.name === 'createdAt' || field.name === 'updatedAt'), - externalId: field.externalId || false, - trackHistory: field.trackHistory || field.track_history || false, - indexed: field.indexed || false, - referenceTo: field.reference_to || field.referenceTo || undefined, - formula: field.formula || undefined, - }; -} +import { toObjectDefinition, toFieldDefinition, type MetadataObject } from '../../utils/metadataConverters'; // ============================================================================ // Object Detail View @@ -265,24 +168,111 @@ function ObjectDetailView({ object, metadataObject, onBack, metadataService, onR )} - {/* Relationships */} - {object.relationships && object.relationships.length > 0 && ( -
- - - Relationships - -
- {object.relationships.map((rel, i) => ( - - {rel.label || rel.relatedObject} ({rel.type}) +
+ + {/* Relationships Section */} +
+

+ + Relationships +

+ {object.relationships && object.relationships.length > 0 ? ( +
+ {object.relationships.map((rel, i) => ( +
+ + {rel.type} - ))} -
+
+ {rel.label || rel.relatedObject} + {rel.label && rel.label !== rel.relatedObject && ( + → {rel.relatedObject} + )} + {rel.foreignKey && ( + (FK: {rel.foreignKey}) + )} +
+
+ ))}
+ ) : ( +

No relationships defined for this object.

)}
+ {/* Keys Section */} +
+

+ + Keys +

+ {(() => { + const keyFields = displayFields.filter( + (f) => f.unique || f.name === 'id' || f.externalId + ); + if (keyFields.length > 0) { + return ( +
+ {keyFields.map((kf) => ( +
+ + {kf.name === 'id' ? 'Primary Key' : kf.externalId ? 'External ID' : 'Unique'} + +
+ {kf.label || kf.name} + ({kf.type}) +
+
+ ))} +
+ ); + } + return ( +

No unique keys or primary keys found.

+ ); + })()} +
+ + {/* Data Experience Section */} +
+

+ + Data Experience +

+
+
+ +

Forms

+

Design forms for data entry

+
+
+ +

Views

+

Configure list and detail views

+
+
+ +

Dashboards

+

Build visual dashboards

+
+
+
+ + {/* Inline Data Preview (placeholder) */} +
+

+ + Data Preview + +
+
+

Sample Data

+

+ Live data preview for “{object.label}” will be available here +

+ + + {/* Field Management Section */}
{saving && ( @@ -291,6 +281,13 @@ function ObjectDetailView({ object, metadataObject, onBack, metadataService, onR Saving field changes…
)} + {/* System field hint */} + {displayFields.some((f) => f.isSystem) && ( +
+ + System fields (e.g. id, createdAt, updatedAt) are read-only and cannot be edited or deleted. +
+ )} > = { - 'layout-grid': LayoutGrid, - 'database': Database, - 'layout-dashboard': LayoutDashboard, - 'file-text': FileText, - 'bar-chart-3': BarChart3, -}; - -function resolveIcon(iconName: string): React.ComponentType<{ className?: string }> { - return ICON_MAP[iconName] ?? Database; -} - export function SystemHubPage() { const navigate = useNavigate(); const { appName } = useParams(); @@ -122,7 +103,7 @@ export function SystemHubPage() { const href = cfg.hasCustomPage && cfg.customRoute ? `${basePath}${cfg.customRoute}` : `${basePath}/system/metadata/${cfg.type}`; - const Icon = resolveIcon(cfg.icon); + const Icon = getIcon(cfg.icon); // Resolve count from metadata context or data source counts let count: number | null = null; diff --git a/apps/console/src/utils/metadataConverters.ts b/apps/console/src/utils/metadataConverters.ts new file mode 100644 index 000000000..8b7c29eef --- /dev/null +++ b/apps/console/src/utils/metadataConverters.ts @@ -0,0 +1,131 @@ +/** + * Metadata Converters + * + * Shared conversion functions for transforming raw metadata API objects + * (from the ObjectStack spec) to the UI types used by ObjectManager and + * FieldDesigner components. + * + * Extracted from ObjectManagerPage to enable reuse across pages. + * + * @module utils/metadataConverters + */ + +import type { ObjectDefinition, DesignerFieldDefinition, DesignerFieldType } from '@object-ui/types'; + +// --------------------------------------------------------------------------- +// Raw metadata shapes (from the ObjectStack API) +// --------------------------------------------------------------------------- + +/** Loose shape of a metadata object definition from the ObjectStack API. */ +export interface MetadataObject { + name?: string; + label?: string | { defaultValue?: string; key?: string }; + pluralLabel?: string; + plural_label?: string; + description?: string | { defaultValue?: string }; + icon?: string; + enabled?: boolean; + fields?: MetadataField[] | Record; + relationships?: Array<{ + object?: string; + relatedObject?: string; + type?: string; + label?: string; + name?: string; + foreign_key?: string; + foreignKey?: string; + }>; +} + +/** Loose shape of a metadata field definition from the ObjectStack API. */ +export interface MetadataField { + name?: string; + label?: string | { defaultValue?: string; key?: string }; + type?: string; + group?: string; + description?: string; + help?: string; + required?: boolean; + unique?: boolean; + readonly?: boolean; + hidden?: boolean; + defaultValue?: string; + default_value?: string; + placeholder?: string; + options?: Array; + externalId?: boolean; + trackHistory?: boolean; + track_history?: boolean; + indexed?: boolean; + reference_to?: string; + referenceTo?: string; + formula?: string; +} + +// --------------------------------------------------------------------------- +// Converters +// --------------------------------------------------------------------------- + +/** + * Convert a metadata object definition (from the API/spec) to the ObjectDefinition + * type used by the ObjectManager component. + */ +export function toObjectDefinition(obj: MetadataObject, index: number): ObjectDefinition { + const fields = Array.isArray(obj.fields) ? obj.fields : Object.values(obj.fields || {}); + return { + id: obj.name || `obj_${index}`, + name: obj.name || '', + label: typeof obj.label === 'object' ? obj.label.defaultValue || obj.label.key || '' : (obj.label || obj.name || ''), + pluralLabel: obj.pluralLabel || obj.plural_label || undefined, + description: typeof obj.description === 'object' ? obj.description.defaultValue : (obj.description || undefined), + icon: obj.icon || undefined, + group: obj.name?.startsWith('sys_') ? 'System Objects' : 'Custom Objects', + sortOrder: index, + isSystem: obj.name?.startsWith('sys_') || false, + enabled: obj.enabled !== false, + fieldCount: fields.length, + relationships: Array.isArray(obj.relationships) + ? obj.relationships.map((r) => ({ + relatedObject: r.object || r.relatedObject || '', + type: r.type || 'one-to-many', + label: r.label || r.name || undefined, + foreignKey: r.foreign_key || r.foreignKey || undefined, + })) + : undefined, + }; +} + +/** + * Convert a metadata field definition to the DesignerFieldDefinition + * type used by the FieldDesigner component. + */ +export function toFieldDefinition(field: MetadataField, index: number): DesignerFieldDefinition { + return { + id: field.name || `fld_${index}`, + name: field.name || '', + label: typeof field.label === 'object' ? field.label.defaultValue || field.label.key || '' : (field.label || field.name || ''), + type: (field.type || 'text') as DesignerFieldType, + group: field.group || undefined, + sortOrder: index, + description: field.description || field.help || undefined, + required: field.required || false, + unique: field.unique || false, + readonly: field.readonly || false, + hidden: field.hidden || false, + defaultValue: field.defaultValue || field.default_value || undefined, + placeholder: field.placeholder || undefined, + options: Array.isArray(field.options) + ? field.options.map((opt) => + typeof opt === 'string' + ? { label: opt, value: opt } + : { label: opt.label || opt.value, value: opt.value, color: opt.color } + ) + : undefined, + isSystem: field.readonly === true && (field.name === 'id' || field.name === 'createdAt' || field.name === 'updatedAt'), + externalId: field.externalId || false, + trackHistory: field.trackHistory || field.track_history || false, + indexed: field.indexed || false, + referenceTo: field.reference_to || field.referenceTo || undefined, + formula: field.formula || undefined, + }; +}