diff --git a/CHANGELOG.md b/CHANGELOG.md index 29f560d80..3b3b453c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Object Manager visual designer** (`@object-ui/plugin-designer`): Enterprise-grade object management interface for creating, editing, deleting, and configuring meta-object definitions. Uses standard ObjectGrid for the list view and ModalForm for create/edit operations. Features include property editing (name, label, plural label, description, icon, group, sort order, enabled toggle), object relationship display, search/filter, system object protection, confirm dialogs for destructive actions, and read-only mode. 18 unit tests. + +- **Field Designer visual designer** (`@object-ui/plugin-designer`): Enterprise-grade field configuration wizard supporting 27 field types with full CRUD operations. Uses standard ObjectGrid for the list view with a specialized FieldEditor panel for advanced type-specific properties. Features include uniqueness constraints, default values, picklist/option set management, read-only, hidden, validation rules (min/max/length/pattern/custom), external ID, history tracking, and database indexing. Type-specific editors for lookup references, formula expressions, and select options. Field type filtering, search, system field protection, and read-only mode. 22 unit tests. + +- **New type definitions** (`@object-ui/types`): Added `ObjectDefinition`, `ObjectDefinitionRelationship`, `ObjectManagerSchema`, `DesignerFieldType` (27 field types), `DesignerFieldOption`, `DesignerValidationRule`, `DesignerFieldDefinition`, and `FieldDesignerSchema` interfaces for the Object Manager and Field Designer components. + +- **New i18n keys for Object Manager and Field Designer** (`@object-ui/i18n`): Added 50 new translation keys per locale across all 10 locale packs (en, zh, ja, ko, de, fr, es, pt, ru, ar) covering both `objectManager` and `fieldDesigner` subsections of `appDesigner`. + +- **Designer translation fallbacks** (`@object-ui/plugin-designer`): Updated `useDesignerTranslation` with fallback translations for all new Object Manager and Field Designer keys. + +- **Console integration** (`@object-ui/console`): Object Manager and Field Designer are now accessible in the console application at `/system/objects`. Added ObjectManagerPage to system admin routes, SystemHubPage card, and sidebar navigation. Selecting an object drills into its FieldDesigner for field configuration. 7 unit tests. + ### Changed - **System settings pages refactored to ObjectView** (`apps/console`): All five system management pages (Users, Organizations, Roles, Permissions, Audit Log) now use the metadata-driven `ObjectView` from `@object-ui/plugin-view` instead of hand-written HTML tables. Each page's UI is driven by the object definitions in `systemObjects.ts`, providing automatic search, sort, filter, and CRUD capabilities. A shared `SystemObjectViewPage` component eliminates code duplication across all system pages. ### Fixed +- **Plugin designer test infrastructure** (`@object-ui/plugin-designer`): Created missing `vitest.setup.ts` with ResizeObserver polyfill and jest-dom matchers. Added `@object-ui/i18n` alias to vite config. These fixes resolved 9 pre-existing test suite failures, bringing total passing tests from 45 to 246. + - **Chinese language pack (zh.ts) untranslated key** (`@object-ui/i18n`): Fixed `console.objectView.toolbarEnabledCount` which was still in English (`'{{count}} of {{total}} enabled'`) — now properly translated to `'已启用 {{count}}/{{total}} 项'`. Also fixed the same untranslated key in all other 8 non-English locales (ja, ko, de, fr, es, pt, ru, ar). - **Hardcoded English strings in platform UI** (`apps/console`, `@object-ui/fields`, `@object-ui/react`, `@object-ui/components`): Replaced hardcoded English strings with i18n `t()` calls: diff --git a/ROADMAP.md b/ROADMAP.md index 0841f6a57..73a3c55c6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,7 @@ > **Spec Version:** @objectstack/spec v3.3.0 > **Client Version:** @objectstack/client v3.3.0 > **Target UX Benchmark:** 🎯 Airtable parity -> **Current Priority:** AppShell Navigation · Designer Interaction · **View Config Live Preview Sync ✅** · Dashboard Config Panel · Airtable UX Polish · **Flow Designer ✅** · **App Creation & Editing Flow ✅** · **System Settings & App Management ✅** · **Right-Side Visual Editor Drawer ✅** +> **Current Priority:** AppShell Navigation · Designer Interaction · **View Config Live Preview Sync ✅** · Dashboard Config Panel · Airtable UX Polish · **Flow Designer ✅** · **App Creation & Editing Flow ✅** · **System Settings & App Management ✅** · **Right-Side Visual Editor Drawer ✅** · **Object Manager & Field Designer ✅** --- @@ -876,6 +876,45 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - [x] 10 unit tests for `useObjectLabel` hook - [x] Zero changes to object metadata files or translation files +### P1.16 Object Manager & Field Designer ✅ + +> **Status:** Complete — `ObjectManager` and `FieldDesigner` components shipped in `@object-ui/plugin-designer`. + +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. + +**Object Manager (`ObjectManager`):** +- [x] CRUD operations on object definitions (custom and system objects) +- [x] Visual configuration of object properties (name, label, plural label, description, icon, group, sort order, enabled) +- [x] Object relationship display and maintenance +- [x] Inline property editor with collapsible sections +- [x] Search/filter functionality +- [x] Grouped object display with badges +- [x] System object protection (non-deletable, name-locked) +- [x] Read-only mode support +- [x] Confirm dialog for destructive actions +- [x] 18 unit tests + +**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 +- [x] Field grouping, sorting, and layout management +- [x] System reserved field protection +- [x] Type-specific property editors (lookup reference, formula expression, select options) +- [x] Validation rule builder (min, max, minLength, maxLength, pattern, custom) +- [x] Search and type-based filtering +- [x] Read-only mode support +- [x] 22 unit tests + +**Type Definitions (`@object-ui/types`):** +- [x] `ObjectDefinition`, `ObjectDefinitionRelationship`, `ObjectManagerSchema` +- [x] `DesignerFieldType` (27 types), `DesignerFieldOption`, `DesignerValidationRule` +- [x] `DesignerFieldDefinition`, `FieldDesignerSchema` + +**i18n Support:** +- [x] Full translations for all 10 locales (en, zh, ja, ko, de, fr, es, pt, ru, ar) +- [x] 50 new translation keys per locale (objectManager + fieldDesigner sections) +- [x] Fallback translations in `useDesignerTranslation` for standalone usage + --- ## 🧩 P2 — Polish & Advanced Features diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 28bf8f66c..e317f38d2 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -47,6 +47,7 @@ const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage').then( // System Admin Pages (lazy — rarely accessed) const SystemHubPage = lazy(() => import('./pages/system/SystemHubPage').then(m => ({ default: m.SystemHubPage }))); const AppManagementPage = lazy(() => import('./pages/system/AppManagementPage').then(m => ({ default: m.AppManagementPage }))); +const ObjectManagerPage = lazy(() => import('./pages/system/ObjectManagerPage').then(m => ({ default: m.ObjectManagerPage }))); const UserManagementPage = lazy(() => import('./pages/system/UserManagementPage').then(m => ({ default: m.UserManagementPage }))); const OrgManagementPage = lazy(() => import('./pages/system/OrgManagementPage').then(m => ({ default: m.OrgManagementPage }))); const RoleManagementPage = lazy(() => import('./pages/system/RoleManagementPage').then(m => ({ default: m.RoleManagementPage }))); @@ -287,6 +288,8 @@ export function AppContent() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -379,6 +382,8 @@ export function AppContent() { {/* System Administration Routes */} } /> } /> + } /> + } /> } /> } /> } /> @@ -501,6 +506,8 @@ function SystemRoutes() { } /> } /> + } /> + } /> } /> } /> } /> diff --git a/apps/console/src/__tests__/ObjectManagerPage.test.tsx b/apps/console/src/__tests__/ObjectManagerPage.test.tsx new file mode 100644 index 000000000..db09db0aa --- /dev/null +++ b/apps/console/src/__tests__/ObjectManagerPage.test.tsx @@ -0,0 +1,175 @@ +/** + * ObjectManagerPage tests + * + * Tests for the system administration Object Manager page that integrates + * ObjectManager and FieldDesigner from @object-ui/plugin-designer. + * Covers list view, detail view with URL-based navigation, and field management. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { ObjectManagerPage } from '../pages/system/ObjectManagerPage'; + +// Mock MetadataProvider +vi.mock('../context/MetadataProvider', () => ({ + useMetadata: () => ({ + objects: [ + { + name: 'account', + label: 'Accounts', + icon: 'Building', + description: 'Customer accounts', + enabled: true, + fields: [ + { name: 'id', type: 'text', label: 'ID', readonly: true }, + { name: 'name', type: 'text', label: 'Account Name', required: true }, + { name: 'email', type: 'email', label: 'Email' }, + { name: 'status', type: 'select', label: 'Status', options: ['active', 'inactive'] }, + ], + relationships: [ + { object: 'contact', type: 'one-to-many', name: 'contacts' }, + ], + }, + { + name: 'contact', + label: 'Contacts', + icon: 'Users', + fields: [ + { name: 'id', type: 'text', label: 'ID', readonly: true }, + { name: 'name', type: 'text', label: 'Name', required: true }, + ], + }, + { + name: 'sys_user', + label: 'Users', + icon: 'Users', + fields: [ + { name: 'id', type: 'text', label: 'ID', readonly: true }, + { name: 'name', type: 'text', label: 'Name', required: true }, + { name: 'email', type: 'email', label: 'Email', required: true }, + ], + }, + ], + refresh: vi.fn(), + }), +})); + +// Mock sonner toast +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +function renderPage(route = '/system/objects') { + return render( + + + } /> + } /> + } /> + } /> + + + ); +} + +describe('ObjectManagerPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('List View', () => { + it('should render the page with Object Manager title', () => { + renderPage(); + const titles = screen.getAllByText('Object Manager'); + expect(titles.length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Manage object definitions and field configurations')).toBeDefined(); + }); + + it('should render the page container', () => { + renderPage(); + expect(screen.getByTestId('object-manager-page')).toBeDefined(); + }); + + it('should render the ObjectManager component with objects from metadata', () => { + renderPage(); + expect(screen.getByTestId('object-manager')).toBeDefined(); + }); + + it('should display metadata objects via ObjectGrid', async () => { + renderPage(); + // ObjectGrid (from plugin-grid) renders the data asynchronously via ValueDataSource + await waitFor(() => { + const content = screen.getByTestId('object-manager').textContent; + expect(content).toBeDefined(); + }); + }); + }); + + describe('Detail View (URL-based)', () => { + it('should show object detail page when navigating to /system/objects/:objectName', () => { + renderPage('/system/objects/account'); + expect(screen.getByTestId('object-detail-view')).toBeDefined(); + const titles = screen.getAllByText('Accounts'); + expect(titles.length).toBeGreaterThanOrEqual(1); + }); + + it('should show object properties section', () => { + renderPage('/system/objects/account'); + expect(screen.getByTestId('object-properties')).toBeDefined(); + expect(screen.getByText('API Name')).toBeDefined(); + expect(screen.getByText('account')).toBeDefined(); + }); + + it('should show field management section with FieldDesigner', () => { + renderPage('/system/objects/account'); + expect(screen.getByTestId('field-management-section')).toBeDefined(); + expect(screen.getByTestId('field-designer')).toBeDefined(); + }); + + it('should show back button to return to object list', () => { + renderPage('/system/objects/account'); + expect(screen.getByTestId('back-to-objects')).toBeDefined(); + }); + + it('should navigate back to object list when back button is clicked', async () => { + renderPage('/system/objects/account'); + const backBtn = screen.getByTestId('back-to-objects'); + fireEvent.click(backBtn); + await waitFor(() => { + expect(screen.getByTestId('object-manager')).toBeDefined(); + }); + }); + + 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(); + }); + }); + + describe('Object Selection via ObjectGrid', () => { + it('should navigate to detail when primary field link is clicked', async () => { + renderPage(); + + // ObjectGrid renders data asynchronously via ValueDataSource. + // Wait for primary-field-link buttons to appear. + await waitFor(() => { + const links = screen.queryAllByTestId('primary-field-link'); + expect(links.length).toBeGreaterThan(0); + }, { timeout: 5000 }); + + // Click the first primary field link (should be 'account') + const links = screen.getAllByTestId('primary-field-link'); + fireEvent.click(links[0]); + + // Should navigate to the object detail view + await waitFor(() => { + expect(screen.getByTestId('object-detail-view')).toBeDefined(); + }); + }); + }); +}); diff --git a/apps/console/src/components/AppSidebar.tsx b/apps/console/src/components/AppSidebar.tsx index b5e1c4a4a..f8e452b9b 100644 --- a/apps/console/src/components/AppSidebar.tsx +++ b/apps/console/src/components/AppSidebar.tsx @@ -257,6 +257,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri const systemFallbackNavigation: NavigationItem[] = React.useMemo(() => [ { id: 'sys-settings', label: 'System Settings', type: 'url' as const, url: '/system', icon: 'settings' }, { id: 'sys-apps', label: 'Applications', type: 'url' as const, url: '/system/apps', icon: 'layout-grid' }, + { id: 'sys-objects', label: 'Object Manager', type: 'url' as const, url: '/system/objects', icon: 'database' }, { id: 'sys-users', label: 'Users', type: 'url' as const, url: '/system/users', icon: 'users' }, { id: 'sys-orgs', label: 'Organizations', type: 'url' as const, url: '/system/organizations', icon: 'building-2' }, { id: 'sys-roles', label: 'Roles', type: 'url' as const, url: '/system/roles', icon: 'shield' }, diff --git a/apps/console/src/pages/system/ObjectManagerPage.tsx b/apps/console/src/pages/system/ObjectManagerPage.tsx new file mode 100644 index 000000000..9b44bb3e3 --- /dev/null +++ b/apps/console/src/pages/system/ObjectManagerPage.tsx @@ -0,0 +1,349 @@ +/** + * Object Manager Page + * + * System administration page for managing object definitions and their fields. + * Integrates both ObjectManager (object list/CRUD) and FieldDesigner (field + * configuration wizard) from @object-ui/plugin-designer. + * + * Routes: + * /system/objects → Object list (ObjectManager) + * /system/objects/:objectName → Object detail with field management (FieldDesigner) + */ + +import { useState, useCallback, useMemo } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Button, Badge } from '@object-ui/components'; +import { ArrowLeft, Database, Settings2, Link2 } from 'lucide-react'; +import { ObjectManager, FieldDesigner } from '@object-ui/plugin-designer'; +import type { ObjectDefinition, DesignerFieldDefinition } from '@object-ui/types'; +import { toast } from 'sonner'; +import { useMetadata } from '../../context/MetadataProvider'; + +/** Loose shape of a metadata object definition from the ObjectStack API. */ +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. */ +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', + 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, + }; +} + +// ============================================================================ +// Object Detail View +// ============================================================================ + +interface ObjectDetailViewProps { + object: ObjectDefinition; + metadataObject: MetadataObject | undefined; + onBack: () => void; +} + +function ObjectDetailView({ object, metadataObject, onBack }: ObjectDetailViewProps) { + const rawFields = metadataObject + ? (Array.isArray(metadataObject.fields) ? metadataObject.fields : Object.values(metadataObject.fields || {})) + : []; + const fields = useMemo(() => rawFields.map(toFieldDefinition), [rawFields]); + const [localFields, setLocalFields] = useState(null); + const displayFields = localFields ?? fields; + + const handleFieldsChange = useCallback((updated: DesignerFieldDefinition[]) => { + setLocalFields(updated); + toast.success('Field configuration updated'); + }, []); + + return ( +
+ {/* Back navigation + header */} +
+
+ +
+ +
+
+

+ {object.label} +

+

+ {object.description || object.name} +

+
+
+
+ + {/* Object Properties Card */} +
+

+ + Object Properties +

+
+
+ API Name +

{object.name}

+
+
+ Label +

{object.label}

+
+ {object.pluralLabel && ( +
+ Plural Label +

{object.pluralLabel}

+
+ )} + {object.group && ( +
+ Group +

{object.group}

+
+ )} +
+ Status +

+ + {object.enabled !== false ? 'Enabled' : 'Disabled'} + +

+
+
+ Fields + {object.fieldCount ?? fields.length} +
+ {object.isSystem && ( +
+ Type +

+ System Object +

+
+ )} +
+ {/* Relationships */} + {object.relationships && object.relationships.length > 0 && ( +
+ + + Relationships + +
+ {object.relationships.map((rel, i) => ( + + {rel.label || rel.relatedObject} ({rel.type}) + + ))} +
+
+ )} +
+ + {/* Field Management Section */} +
+ +
+
+ ); +} + +// ============================================================================ +// Main Page Component +// ============================================================================ + +export function ObjectManagerPage() { + const navigate = useNavigate(); + const { appName, objectName: routeObjectName } = useParams(); + const basePath = appName ? `/apps/${appName}/system/objects` : '/system/objects'; + const { objects: metadataObjects } = useMetadata(); + + // Convert metadata objects to ObjectDefinition[] + const objects = useMemo( + () => (metadataObjects || []).map(toObjectDefinition), + [metadataObjects] + ); + + // State for local object edits + const [localObjects, setLocalObjects] = useState(null); + const displayObjects = localObjects ?? objects; + + // Find selected object from URL param + const selectedObject = useMemo(() => { + if (!routeObjectName) return null; + return displayObjects.find((o) => o.name === routeObjectName) ?? null; + }, [routeObjectName, displayObjects]); + + // Find the raw metadata object for field extraction + const selectedMetadataObject = useMemo(() => { + if (!routeObjectName) return undefined; + return (metadataObjects || []).find((o: MetadataObject) => o.name === routeObjectName); + }, [routeObjectName, metadataObjects]); + + // Navigate to object detail page + const handleSelectObject = useCallback((obj: ObjectDefinition) => { + navigate(`${basePath}/${obj.name}`); + }, [navigate, basePath]); + + // Navigate back to object list + const handleBackToList = useCallback(() => { + navigate(basePath); + }, [navigate, basePath]); + + const handleObjectsChange = useCallback((updated: ObjectDefinition[]) => { + setLocalObjects(updated); + toast.success('Object definitions updated'); + }, []); + + // Detail view mode: show object detail + FieldDesigner + if (selectedObject) { + return ( +
+ +
+ ); + } + + // List view mode: show ObjectManager + return ( +
+ {/* Header */} +
+
+
+ +
+
+

+ Object Manager +

+

+ Manage object definitions and field configurations +

+
+
+
+ + {/* Content */} + +
+ ); +} diff --git a/apps/console/src/pages/system/SystemHubPage.tsx b/apps/console/src/pages/system/SystemHubPage.tsx index 8be655c2c..40ea8087c 100644 --- a/apps/console/src/pages/system/SystemHubPage.tsx +++ b/apps/console/src/pages/system/SystemHubPage.tsx @@ -25,6 +25,7 @@ import { ScrollText, User, Loader2, + Database, } from 'lucide-react'; import { useAdapter } from '../../context/AdapterProvider'; import { useMetadata } from '../../context/MetadataProvider'; @@ -43,10 +44,11 @@ export function SystemHubPage() { const { appName } = useParams(); const basePath = appName ? `/apps/${appName}` : ''; const dataSource = useAdapter(); - const { apps } = useMetadata(); + const { apps, objects: metadataObjects } = useMetadata(); const [counts, setCounts] = useState>({ apps: null, + objects: null, users: null, orgs: null, roles: null, @@ -69,6 +71,7 @@ export function SystemHubPage() { ]); setCounts({ apps: apps?.length ?? 0, + objects: metadataObjects?.length ?? 0, users: usersRes.data?.length ?? 0, orgs: orgsRes.data?.length ?? 0, roles: rolesRes.data?.length ?? 0, @@ -80,7 +83,7 @@ export function SystemHubPage() { } finally { setLoading(false); } - }, [dataSource, apps]); + }, [dataSource, apps, metadataObjects]); useEffect(() => { fetchCounts(); }, [fetchCounts]); @@ -93,6 +96,14 @@ export function SystemHubPage() { countLabel: 'apps', count: counts.apps, }, + { + title: 'Object Manager', + description: 'Manage object definitions and field configurations', + icon: Database, + href: `${basePath}/system/objects`, + countLabel: 'objects', + count: counts.objects ?? null, + }, { title: 'Users', description: 'Manage system users and accounts', diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 8a902bbaf..2f230c80f 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -472,6 +472,58 @@ const ar = { modeLight: 'فاتح', modeDark: 'داكن', mobilePreview: 'معاينة الجوال', + objectManager: { + title: 'مدير الكائنات', + addObject: 'كائن جديد', + searchPlaceholder: 'بحث عن كائنات…', + noObjects: 'لم يتم العثور على كائنات.', + objectName: 'اسم API', + objectLabel: 'التسمية', + pluralLabel: 'التسمية الجمعية', + icon: 'الأيقونة', + selectIcon: 'اختيار أيقونة…', + group: 'المجموعة', + noGroup: 'بدون مجموعة', + sortOrder: 'ترتيب الفرز', + enabled: 'مفعّل', + relationships: 'العلاقات', + systemBadge: 'نظام', + fieldCount: '{{count}} حقول', + ungrouped: 'غير مصنف', + deleteConfirmTitle: 'حذف الكائن؟', + deleteConfirmMessage: 'سيتم حذف الكائن وجميع حقوله نهائيًا. لا يمكن التراجع عن هذا الإجراء.', + }, + fieldDesigner: { + title: 'مصمم الحقول', + addField: 'حقل جديد', + searchPlaceholder: 'بحث عن حقول…', + allTypes: 'كل الأنواع', + noFields: 'لم يتم العثور على حقول.', + fieldName: 'اسم API', + fieldLabel: 'التسمية', + fieldType: 'النوع', + fieldGroup: 'المجموعة', + description: 'الوصف', + required: 'مطلوب', + unique: 'فريد', + readOnly: 'للقراءة فقط', + hidden: 'مخفي', + indexed: 'مفهرس', + externalId: 'معرف خارجي', + trackHistory: 'تتبع السجل', + defaultValue: 'القيمة الافتراضية', + placeholder: 'نص توضيحي', + referenceTo: 'مرجع إلى', + formula: 'صيغة', + options: 'خيارات', + addOption: 'إضافة خيار', + validationRules: 'قواعد التحقق', + addRule: 'إضافة قاعدة', + systemBadge: 'نظام', + ungrouped: 'عام', + deleteConfirmTitle: 'حذف الحقل؟', + deleteConfirmMessage: 'سيتم حذف الحقل نهائيًا. ستفقد البيانات الموجودة في هذا الحقل.', + }, }, console: { title: 'وحدة تحكم ObjectStack', diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 80d2ae56e..b57f222e2 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -480,6 +480,58 @@ const de = { modeLight: 'Hell', modeDark: 'Dunkel', mobilePreview: 'Mobile Vorschau', + objectManager: { + title: 'Objekt-Manager', + addObject: 'Neues Objekt', + searchPlaceholder: 'Objekte suchen…', + noObjects: 'Keine Objekte gefunden.', + objectName: 'API-Name', + objectLabel: 'Bezeichnung', + pluralLabel: 'Pluralbezeichnung', + icon: 'Symbol', + selectIcon: 'Symbol wählen…', + group: 'Gruppe', + noGroup: 'Keine Gruppe', + sortOrder: 'Sortierung', + enabled: 'Aktiviert', + relationships: 'Beziehungen', + systemBadge: 'System', + fieldCount: '{{count}} Felder', + ungrouped: 'Nicht gruppiert', + deleteConfirmTitle: 'Objekt löschen?', + deleteConfirmMessage: 'Das Objekt und alle zugehörigen Felder werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.', + }, + fieldDesigner: { + title: 'Feld-Designer', + addField: 'Neues Feld', + searchPlaceholder: 'Felder suchen…', + allTypes: 'Alle Typen', + noFields: 'Keine Felder gefunden.', + fieldName: 'API-Name', + fieldLabel: 'Bezeichnung', + fieldType: 'Typ', + fieldGroup: 'Gruppe', + description: 'Beschreibung', + required: 'Erforderlich', + unique: 'Eindeutig', + readOnly: 'Schreibgeschützt', + hidden: 'Ausgeblendet', + indexed: 'Indiziert', + externalId: 'Externe ID', + trackHistory: 'Verlauf verfolgen', + defaultValue: 'Standardwert', + placeholder: 'Platzhalter', + referenceTo: 'Verweis auf', + formula: 'Formel', + options: 'Optionen', + addOption: 'Option hinzufügen', + validationRules: 'Validierungsregeln', + addRule: 'Regel hinzufügen', + systemBadge: 'System', + ungrouped: 'Allgemein', + deleteConfirmTitle: 'Feld löschen?', + deleteConfirmMessage: 'Das Feld wird dauerhaft gelöscht. Vorhandene Daten in diesem Feld gehen verloren.', + }, }, console: { title: 'ObjectStack Konsole', diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 7742b9081..166ac5560 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -508,6 +508,58 @@ const en = { modeLight: 'Light', modeDark: 'Dark', mobilePreview: 'Mobile Preview', + objectManager: { + title: 'Object Manager', + addObject: 'New Object', + searchPlaceholder: 'Search objects…', + noObjects: 'No objects found.', + objectName: 'API Name', + objectLabel: 'Label', + pluralLabel: 'Plural Label', + icon: 'Icon', + selectIcon: 'Select icon…', + group: 'Group', + noGroup: 'No Group', + sortOrder: 'Sort Order', + enabled: 'Enabled', + relationships: 'Relationships', + systemBadge: 'System', + fieldCount: '{{count}} fields', + ungrouped: 'Ungrouped', + deleteConfirmTitle: 'Delete Object?', + deleteConfirmMessage: 'This will permanently delete the object and all its fields. This action cannot be undone.', + }, + fieldDesigner: { + title: 'Field Designer', + addField: 'New Field', + searchPlaceholder: 'Search fields…', + allTypes: 'All Types', + noFields: 'No fields found.', + fieldName: 'API Name', + fieldLabel: 'Label', + fieldType: 'Type', + fieldGroup: 'Group', + description: 'Description', + required: 'Required', + unique: 'Unique', + readOnly: 'Read Only', + hidden: 'Hidden', + indexed: 'Indexed', + externalId: 'External ID', + trackHistory: 'Track History', + defaultValue: 'Default Value', + placeholder: 'Placeholder', + referenceTo: 'Reference To', + formula: 'Formula', + options: 'Options', + addOption: 'Add Option', + validationRules: 'Validation Rules', + addRule: 'Add Rule', + systemBadge: 'System', + ungrouped: 'General', + deleteConfirmTitle: 'Delete Field?', + deleteConfirmMessage: 'This will permanently delete the field. Existing data in this field will be lost.', + }, }, console: { title: 'ObjectStack Console', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 349c360f1..ca16d8b9d 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -471,6 +471,58 @@ const es = { modeLight: 'Claro', modeDark: 'Oscuro', mobilePreview: 'Vista previa móvil', + objectManager: { + title: 'Gestor de objetos', + addObject: 'Nuevo objeto', + searchPlaceholder: 'Buscar objetos…', + noObjects: 'No se encontraron objetos.', + objectName: 'Nombre API', + objectLabel: 'Etiqueta', + pluralLabel: 'Etiqueta plural', + icon: 'Icono', + selectIcon: 'Seleccionar icono…', + group: 'Grupo', + noGroup: 'Sin grupo', + sortOrder: 'Orden', + enabled: 'Habilitado', + relationships: 'Relaciones', + systemBadge: 'Sistema', + fieldCount: '{{count}} campos', + ungrouped: 'Sin agrupar', + deleteConfirmTitle: '¿Eliminar objeto?', + deleteConfirmMessage: 'El objeto y todos sus campos se eliminarán permanentemente. Esta acción no se puede deshacer.', + }, + fieldDesigner: { + title: 'Diseñador de campos', + addField: 'Nuevo campo', + searchPlaceholder: 'Buscar campos…', + allTypes: 'Todos los tipos', + noFields: 'No se encontraron campos.', + fieldName: 'Nombre API', + fieldLabel: 'Etiqueta', + fieldType: 'Tipo', + fieldGroup: 'Grupo', + description: 'Descripción', + required: 'Obligatorio', + unique: 'Único', + readOnly: 'Solo lectura', + hidden: 'Oculto', + indexed: 'Indexado', + externalId: 'ID externo', + trackHistory: 'Rastrear historial', + defaultValue: 'Valor predeterminado', + placeholder: 'Texto de ejemplo', + referenceTo: 'Referencia a', + formula: 'Fórmula', + options: 'Opciones', + addOption: 'Agregar opción', + validationRules: 'Reglas de validación', + addRule: 'Agregar regla', + systemBadge: 'Sistema', + ungrouped: 'General', + deleteConfirmTitle: '¿Eliminar campo?', + deleteConfirmMessage: 'El campo se eliminará permanentemente. Los datos existentes en este campo se perderán.', + }, }, console: { title: 'Consola ObjectStack', diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index b26ab15b3..c2b86b974 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -480,6 +480,58 @@ const fr = { modeLight: 'Clair', modeDark: 'Sombre', mobilePreview: 'Aperçu mobile', + objectManager: { + title: 'Gestionnaire d\'objets', + addObject: 'Nouvel objet', + searchPlaceholder: 'Rechercher des objets…', + noObjects: 'Aucun objet trouvé.', + objectName: 'Nom API', + objectLabel: 'Libellé', + pluralLabel: 'Libellé pluriel', + icon: 'Icône', + selectIcon: 'Sélectionner une icône…', + group: 'Groupe', + noGroup: 'Aucun groupe', + sortOrder: 'Ordre de tri', + enabled: 'Activé', + relationships: 'Relations', + systemBadge: 'Système', + fieldCount: '{{count}} champs', + ungrouped: 'Non groupé', + deleteConfirmTitle: 'Supprimer l\'objet ?', + deleteConfirmMessage: 'L\'objet et tous ses champs seront définitivement supprimés. Cette action est irréversible.', + }, + fieldDesigner: { + title: 'Concepteur de champs', + addField: 'Nouveau champ', + searchPlaceholder: 'Rechercher des champs…', + allTypes: 'Tous les types', + noFields: 'Aucun champ trouvé.', + fieldName: 'Nom API', + fieldLabel: 'Libellé', + fieldType: 'Type', + fieldGroup: 'Groupe', + description: 'Description', + required: 'Obligatoire', + unique: 'Unique', + readOnly: 'Lecture seule', + hidden: 'Masqué', + indexed: 'Indexé', + externalId: 'ID externe', + trackHistory: 'Suivi de l\'historique', + defaultValue: 'Valeur par défaut', + placeholder: 'Texte indicatif', + referenceTo: 'Référence vers', + formula: 'Formule', + options: 'Options', + addOption: 'Ajouter une option', + validationRules: 'Règles de validation', + addRule: 'Ajouter une règle', + systemBadge: 'Système', + ungrouped: 'Général', + deleteConfirmTitle: 'Supprimer le champ ?', + deleteConfirmMessage: 'Le champ sera définitivement supprimé. Les données existantes seront perdues.', + }, }, console: { title: 'Console ObjectStack', diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index eeaf1c960..30177953b 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -482,6 +482,58 @@ const ja = { modeLight: 'ライト', modeDark: 'ダーク', mobilePreview: 'モバイルプレビュー', + objectManager: { + title: 'オブジェクトマネージャー', + addObject: '新規オブジェクト', + searchPlaceholder: 'オブジェクトを検索…', + noObjects: 'オブジェクトが見つかりません。', + objectName: 'API名', + objectLabel: 'ラベル', + pluralLabel: '複数形ラベル', + icon: 'アイコン', + selectIcon: 'アイコンを選択…', + group: 'グループ', + noGroup: 'グループなし', + sortOrder: '並び順', + enabled: '有効', + relationships: 'リレーション', + systemBadge: 'システム', + fieldCount: '{{count}} フィールド', + ungrouped: '未分類', + deleteConfirmTitle: 'オブジェクトを削除しますか?', + deleteConfirmMessage: 'このオブジェクトとそのすべてのフィールドが完全に削除されます。この操作は元に戻せません。', + }, + fieldDesigner: { + title: 'フィールドデザイナー', + addField: '新規フィールド', + searchPlaceholder: 'フィールドを検索…', + allTypes: 'すべてのタイプ', + noFields: 'フィールドが見つかりません。', + fieldName: 'API名', + fieldLabel: 'ラベル', + fieldType: 'タイプ', + fieldGroup: 'グループ', + description: '説明', + required: '必須', + unique: 'ユニーク', + readOnly: '読み取り専用', + hidden: '非表示', + indexed: 'インデックス', + externalId: '外部ID', + trackHistory: '履歴追跡', + defaultValue: 'デフォルト値', + placeholder: 'プレースホルダー', + referenceTo: '参照先', + formula: '数式', + options: 'オプション', + addOption: 'オプション追加', + validationRules: '入力規則', + addRule: 'ルール追加', + systemBadge: 'システム', + ungrouped: '一般', + deleteConfirmTitle: 'フィールドを削除しますか?', + deleteConfirmMessage: 'このフィールドは完全に削除されます。既存のデータは失われます。', + }, }, console: { title: 'ObjectStack コンソール', diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 6560a68dd..b44ffa508 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -471,6 +471,58 @@ const ko = { modeLight: '라이트', modeDark: '다크', mobilePreview: '모바일 미리보기', + objectManager: { + title: '개체 관리자', + addObject: '새 개체', + searchPlaceholder: '개체 검색…', + noObjects: '개체를 찾을 수 없습니다.', + objectName: 'API 이름', + objectLabel: '레이블', + pluralLabel: '복수 레이블', + icon: '아이콘', + selectIcon: '아이콘 선택…', + group: '그룹', + noGroup: '그룹 없음', + sortOrder: '정렬 순서', + enabled: '활성화', + relationships: '관계', + systemBadge: '시스템', + fieldCount: '{{count}} 필드', + ungrouped: '미분류', + deleteConfirmTitle: '개체를 삭제하시겠습니까?', + deleteConfirmMessage: '이 개체와 모든 필드가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.', + }, + fieldDesigner: { + title: '필드 디자이너', + addField: '새 필드', + searchPlaceholder: '필드 검색…', + allTypes: '모든 유형', + noFields: '필드를 찾을 수 없습니다.', + fieldName: 'API 이름', + fieldLabel: '레이블', + fieldType: '유형', + fieldGroup: '그룹', + description: '설명', + required: '필수', + unique: '고유', + readOnly: '읽기 전용', + hidden: '숨김', + indexed: '인덱스', + externalId: '외부 ID', + trackHistory: '이력 추적', + defaultValue: '기본값', + placeholder: '플레이스홀더', + referenceTo: '참조 대상', + formula: '수식', + options: '옵션', + addOption: '옵션 추가', + validationRules: '유효성 규칙', + addRule: '규칙 추가', + systemBadge: '시스템', + ungrouped: '일반', + deleteConfirmTitle: '필드를 삭제하시겠습니까?', + deleteConfirmMessage: '이 필드가 영구적으로 삭제됩니다. 기존 데이터가 손실됩니다.', + }, }, console: { title: 'ObjectStack 콘솔', diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 3a1878c87..12c18da5f 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -471,6 +471,58 @@ const pt = { modeLight: 'Claro', modeDark: 'Escuro', mobilePreview: 'Pré-visualização móvel', + objectManager: { + title: 'Gerenciador de Objetos', + addObject: 'Novo Objeto', + searchPlaceholder: 'Pesquisar objetos…', + noObjects: 'Nenhum objeto encontrado.', + objectName: 'Nome API', + objectLabel: 'Rótulo', + pluralLabel: 'Rótulo plural', + icon: 'Ícone', + selectIcon: 'Selecionar ícone…', + group: 'Grupo', + noGroup: 'Sem grupo', + sortOrder: 'Ordem', + enabled: 'Habilitado', + relationships: 'Relacionamentos', + systemBadge: 'Sistema', + fieldCount: '{{count}} campos', + ungrouped: 'Sem grupo', + deleteConfirmTitle: 'Excluir objeto?', + deleteConfirmMessage: 'O objeto e todos os seus campos serão excluídos permanentemente. Esta ação não pode ser desfeita.', + }, + fieldDesigner: { + title: 'Designer de Campos', + addField: 'Novo Campo', + searchPlaceholder: 'Pesquisar campos…', + allTypes: 'Todos os tipos', + noFields: 'Nenhum campo encontrado.', + fieldName: 'Nome API', + fieldLabel: 'Rótulo', + fieldType: 'Tipo', + fieldGroup: 'Grupo', + description: 'Descrição', + required: 'Obrigatório', + unique: 'Único', + readOnly: 'Somente leitura', + hidden: 'Oculto', + indexed: 'Indexado', + externalId: 'ID Externo', + trackHistory: 'Rastrear histórico', + defaultValue: 'Valor padrão', + placeholder: 'Texto de exemplo', + referenceTo: 'Referência para', + formula: 'Fórmula', + options: 'Opções', + addOption: 'Adicionar opção', + validationRules: 'Regras de validação', + addRule: 'Adicionar regra', + systemBadge: 'Sistema', + ungrouped: 'Geral', + deleteConfirmTitle: 'Excluir campo?', + deleteConfirmMessage: 'O campo será excluído permanentemente. Os dados existentes neste campo serão perdidos.', + }, }, console: { title: 'Console ObjectStack', diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 8be9298a7..fb8782083 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -491,6 +491,58 @@ const ru = { modeLight: 'Светлая', modeDark: 'Тёмная', mobilePreview: 'Мобильный предпросмотр', + objectManager: { + title: 'Менеджер объектов', + addObject: 'Новый объект', + searchPlaceholder: 'Поиск объектов…', + noObjects: 'Объекты не найдены.', + objectName: 'Имя API', + objectLabel: 'Метка', + pluralLabel: 'Метка (мн. число)', + icon: 'Иконка', + selectIcon: 'Выбрать иконку…', + group: 'Группа', + noGroup: 'Без группы', + sortOrder: 'Порядок сортировки', + enabled: 'Включён', + relationships: 'Связи', + systemBadge: 'Системный', + fieldCount: '{{count}} полей', + ungrouped: 'Без группы', + deleteConfirmTitle: 'Удалить объект?', + deleteConfirmMessage: 'Объект и все его поля будут удалены безвозвратно. Это действие невозможно отменить.', + }, + fieldDesigner: { + title: 'Дизайнер полей', + addField: 'Новое поле', + searchPlaceholder: 'Поиск полей…', + allTypes: 'Все типы', + noFields: 'Поля не найдены.', + fieldName: 'Имя API', + fieldLabel: 'Метка', + fieldType: 'Тип', + fieldGroup: 'Группа', + description: 'Описание', + required: 'Обязательное', + unique: 'Уникальное', + readOnly: 'Только чтение', + hidden: 'Скрытое', + indexed: 'Индексированное', + externalId: 'Внешний ID', + trackHistory: 'Отслеживание истории', + defaultValue: 'Значение по умолчанию', + placeholder: 'Подсказка', + referenceTo: 'Ссылка на', + formula: 'Формула', + options: 'Варианты', + addOption: 'Добавить вариант', + validationRules: 'Правила валидации', + addRule: 'Добавить правило', + systemBadge: 'Системное', + ungrouped: 'Общее', + deleteConfirmTitle: 'Удалить поле?', + deleteConfirmMessage: 'Поле будет удалено безвозвратно. Существующие данные будут потеряны.', + }, }, console: { title: 'Консоль ObjectStack', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 8ca7a5580..737d8ddc5 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -508,6 +508,58 @@ const zh = { modeLight: '浅色', modeDark: '深色', mobilePreview: '移动端预览', + objectManager: { + title: '对象管理器', + addObject: '新建对象', + searchPlaceholder: '搜索对象…', + noObjects: '未找到对象。', + objectName: 'API 名称', + objectLabel: '标签', + pluralLabel: '复数标签', + icon: '图标', + selectIcon: '选择图标…', + group: '分组', + noGroup: '无分组', + sortOrder: '排序', + enabled: '启用', + relationships: '关系', + systemBadge: '系统', + fieldCount: '{{count}} 个字段', + ungrouped: '未分组', + deleteConfirmTitle: '删除对象?', + deleteConfirmMessage: '这将永久删除该对象及其所有字段。此操作无法撤销。', + }, + fieldDesigner: { + title: '字段设计器', + addField: '新建字段', + searchPlaceholder: '搜索字段…', + allTypes: '所有类型', + noFields: '未找到字段。', + fieldName: 'API 名称', + fieldLabel: '标签', + fieldType: '类型', + fieldGroup: '分组', + description: '描述', + required: '必填', + unique: '唯一', + readOnly: '只读', + hidden: '隐藏', + indexed: '索引', + externalId: '外部 ID', + trackHistory: '追踪历史', + defaultValue: '默认值', + placeholder: '占位文本', + referenceTo: '引用对象', + formula: '公式', + options: '选项', + addOption: '添加选项', + validationRules: '验证规则', + addRule: '添加规则', + systemBadge: '系统', + ungrouped: '通用', + deleteConfirmTitle: '删除字段?', + deleteConfirmMessage: '这将永久删除该字段。此字段中的现有数据将丢失。', + }, }, console: { title: 'ObjectStack 控制台', diff --git a/packages/plugin-designer/package.json b/packages/plugin-designer/package.json index 4325f6a41..8e43a8847 100644 --- a/packages/plugin-designer/package.json +++ b/packages/plugin-designer/package.json @@ -26,11 +26,25 @@ "peerDependencies": { "@object-ui/components": "workspace:*", "@object-ui/core": "workspace:*", + "@object-ui/fields": "workspace:*", + "@object-ui/plugin-form": "workspace:*", + "@object-ui/plugin-grid": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, + "peerDependenciesMeta": { + "@object-ui/plugin-grid": { + "optional": true + }, + "@object-ui/plugin-form": { + "optional": true + }, + "@object-ui/fields": { + "optional": true + } + }, "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/packages/plugin-designer/src/FieldDesigner.tsx b/packages/plugin-designer/src/FieldDesigner.tsx new file mode 100644 index 000000000..0cf7fdc1e --- /dev/null +++ b/packages/plugin-designer/src/FieldDesigner.tsx @@ -0,0 +1,680 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * FieldDesigner Component + * + * Enterprise-grade visual designer for configuring object fields. + * Uses standard ObjectGrid for the list view and a specialized + * FieldEditor panel for advanced property editing (type picker, + * options editor, validation rules, conditional fields). + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import type { DesignerFieldDefinition, DesignerFieldType, DesignerFieldOption, DesignerValidationRule } from '@object-ui/types'; +import type { ObjectGridSchema, ListColumn } from '@object-ui/types'; +import { ObjectGrid } from '@object-ui/plugin-grid'; +import { ValueDataSource } from '@object-ui/core'; +import { + Plus, + Trash2, + ChevronDown, + ChevronUp, + Columns3, + Settings2, + Lock, + Hash, + Type, + Calendar, + ToggleLeft, + ListOrdered, + Link2, + AtSign, + Phone, + Globe, + FileText, + Image, + Palette, + Code, + MapPin, + Star, + SlidersHorizontal, + X, +} from 'lucide-react'; +import { clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; +import { useDesignerTranslation } from './hooks/useDesignerTranslation'; +import { useConfirmDialog } from './hooks/useConfirmDialog'; +import { ConfirmDialog } from './components/ConfirmDialog'; + +function cn(...inputs: (string | undefined | false)[]) { + return twMerge(clsx(inputs)); +} + +// ============================================================================ +// Types +// ============================================================================ + +export interface FieldDesignerProps { + /** Object name this designer belongs to */ + objectName: string; + /** List of field definitions */ + fields: DesignerFieldDefinition[]; + /** Callback when fields change */ + onFieldsChange?: (fields: DesignerFieldDefinition[]) => void; + /** Read-only mode */ + readOnly?: boolean; + /** CSS class */ + className?: string; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const FIELD_TYPE_META: Record }> = { + text: { label: 'Text', Icon: Type }, + textarea: { label: 'Text Area', Icon: FileText }, + number: { label: 'Number', Icon: Hash }, + boolean: { label: 'Checkbox', Icon: ToggleLeft }, + date: { label: 'Date', Icon: Calendar }, + datetime: { label: 'Date/Time', Icon: Calendar }, + time: { label: 'Time', Icon: Calendar }, + select: { label: 'Picklist', Icon: ListOrdered }, + email: { label: 'Email', Icon: AtSign }, + phone: { label: 'Phone', Icon: Phone }, + url: { label: 'URL', Icon: Globe }, + password: { label: 'Password', Icon: Lock }, + currency: { label: 'Currency', Icon: Hash }, + percent: { label: 'Percent', Icon: Hash }, + lookup: { label: 'Lookup', Icon: Link2 }, + formula: { label: 'Formula', Icon: Code }, + autonumber: { label: 'Auto Number', Icon: Hash }, + file: { label: 'File', Icon: FileText }, + image: { label: 'Image', Icon: Image }, + markdown: { label: 'Markdown', Icon: FileText }, + html: { label: 'Rich Text', Icon: FileText }, + color: { label: 'Color', Icon: Palette }, + code: { label: 'Code', Icon: Code }, + location: { label: 'Location', Icon: MapPin }, + address: { label: 'Address', Icon: MapPin }, + rating: { label: 'Rating', Icon: Star }, + slider: { label: 'Slider', Icon: SlidersHorizontal }, +}; + +const ALL_FIELD_TYPES = Object.keys(FIELD_TYPE_META) as DesignerFieldType[]; + +// ============================================================================ +// Field Property Editor (specialized panel) +// ============================================================================ + +interface FieldEditorProps { + field: DesignerFieldDefinition; + onChange: (updated: DesignerFieldDefinition) => void; + onClose: () => void; + readOnly: boolean; + t: (key: string, options?: Record) => string; +} + +function FieldEditor({ field, onChange, onClose, readOnly, t }: FieldEditorProps) { + const update = useCallback( + (partial: Partial) => { + onChange({ ...field, ...partial }); + }, + [field, onChange] + ); + + const addOption = useCallback(() => { + const options = field.options || []; + const newOption: DesignerFieldOption = { + label: `Option ${options.length + 1}`, + value: `option_${options.length + 1}`, + }; + update({ options: [...options, newOption] }); + }, [field.options, update]); + + const removeOption = useCallback( + (idx: number) => { + const options = [...(field.options || [])]; + options.splice(idx, 1); + update({ options }); + }, + [field.options, update] + ); + + const updateOption = useCallback( + (idx: number, partial: Partial) => { + const options = [...(field.options || [])]; + options[idx] = { ...options[idx], ...partial }; + update({ options }); + }, + [field.options, update] + ); + + const addValidationRule = useCallback(() => { + const rules = field.validationRules || []; + const newRule: DesignerValidationRule = { + type: 'minLength', + value: 0, + message: '', + }; + update({ validationRules: [...rules, newRule] }); + }, [field.validationRules, update]); + + const removeValidationRule = useCallback( + (idx: number) => { + const rules = [...(field.validationRules || [])]; + rules.splice(idx, 1); + update({ validationRules: rules }); + }, + [field.validationRules, update] + ); + + return ( +
+ {/* Editor header with close button */} +
+ + {t('common.edit')} — {field.label} + + +
+ + {/* Name */} +
+ + update({ name: e.target.value })} + disabled={readOnly || field.isSystem} + data-testid="field-editor-name" + className="block w-full rounded-md border border-gray-300 px-2 py-1 text-xs shadow-sm outline-none transition-colors focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-100" + placeholder="api_name" + /> +
+ + {/* Label */} +
+ + update({ label: e.target.value })} + disabled={readOnly} + data-testid="field-editor-label" + className="block w-full rounded-md border border-gray-300 px-2 py-1 text-xs shadow-sm outline-none transition-colors focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-100" + placeholder="Display Label" + /> +
+ + {/* Type */} +
+ + +
+ + {/* Group */} +
+ + update({ group: e.target.value })} + disabled={readOnly} + data-testid="field-editor-group" + className="block w-full rounded-md border border-gray-300 px-2 py-1 text-xs shadow-sm outline-none transition-colors focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-100" + placeholder="Field Group" + /> +
+ + {/* Description */} +
+ +