diff --git a/CHANGELOG.md b/CHANGELOG.md index dc3462da1..ae5e18476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Metadata service layer** (`@object-ui/console`): New `MetadataService` class (`services/MetadataService.ts`) that encapsulates object and field CRUD operations against the ObjectStack metadata API (`client.meta.saveItem`). Provides `saveObject()`, `deleteObject()`, and `saveFields()` methods with automatic cache invalidation. Includes static `diffObjects()` and `diffFields()` helpers to detect create/update/delete changes between arrays. Companion `useMetadataService` hook (`hooks/useMetadataService.ts`) provides a memoised service instance from the `useAdapter()` context. 16 new unit tests. + +### Fixed + +- **Object/field changes now persist to backend** (`@object-ui/console`): Refactored `ObjectManagerPage` so that `handleObjectsChange` and `handleFieldsChange` call the MetadataService API instead of only updating local state. Implements optimistic update pattern — UI updates immediately, rolls back on API failure. After successful persistence, the MetadataProvider is refreshed so changes survive page reloads. Toast messages now accurately reflect the operation performed (create/update/delete) and display error details on failure. Added saving state indicators with a loading spinner during API operations. + +- **ObjectManager & FieldDesigner read-only grid fix** (`@object-ui/plugin-designer`): Added `operations: { create: true, update: true, delete: true }` to the `ObjectGridSchema` in both `ObjectManager` and `FieldDesigner` components. The real `ObjectGrid` requires `schema.operations` to render action buttons (add/edit/delete); without it, the grid renders as read-only regardless of the `readOnly` prop or callback handlers. The `operations` property is now conditionally set based on the `readOnly` prop. + +- **FieldDesigner modal dialog for add/edit** (`@object-ui/plugin-designer`): Replaced the inline `FieldEditor` panel (rendered below the grid) with a proper `ModalForm` dialog, matching the `ObjectManager` pattern. Add Field and Edit Field now open a modal with all field properties (name, label, type, group, description, toggles for required/unique/readonly/hidden/indexed/externalId/trackHistory, default value, placeholder, referenceTo, formula). This provides a consistent, professional UX across both object and field management. + +- **Duplicate action column in ObjectGrid** (`@object-ui/plugin-grid`): Fixed a bug where `ObjectGrid` rendered two action columns when `schema.operations` was set — one via `RowActionMenu` (working dropdown with Edit/Delete) and another via DataTable's built-in `rowActions` (inline buttons calling unset `schema.onRowEdit`/`schema.onRowDelete`). The DataTable's `rowActions` is now only enabled for inline-editable grids, preventing the duplicate column and dead buttons. + - **AI service discovery** (`@object-ui/react`): Added `ai` service type to `DiscoveryInfo.services` interface with `enabled`, `status`, and `route` fields. Added `isAiEnabled` convenience property to `useDiscovery()` hook return value — returns `true` only when `services.ai.enabled === true` and `services.ai.status === 'available'`, defaults to `false` otherwise. - **Conditional chatbot rendering** (`@object-ui/console`): Console floating chatbot (FAB) now only renders when the AI service is detected as available via `useDiscovery().isAiEnabled`. Previously the chatbot was always visible; now it is hidden when the server has no AI plugin installed. diff --git a/ROADMAP.md b/ROADMAP.md index 10fae65b4..bdfe82cf0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -924,6 +924,18 @@ Enterprise-grade visual designers for managing object definitions and configurin - [x] Read-only mode support - [x] 22 unit tests +**Metadata Persistence (Console Integration):** +- [x] `MetadataService` class wrapping `client.meta.saveItem` for object/field CRUD +- [x] `useMetadataService` hook for adapter-aware service access +- [x] Optimistic update pattern with rollback on API failure +- [x] Object create/update persisted via `saveItem('object', name, data)` +- [x] Object delete via soft-delete (`enabled: false, _deleted: true`) +- [x] Field changes persisted as part of parent object metadata +- [x] MetadataProvider `refresh()` called after successful mutations +- [x] Saving state indicators with loading spinners +- [x] Accurate toast messages (create/update/delete action labels, error details) +- [x] 16 new MetadataService unit tests + 2 ObjectManagerPage API integration tests + **Type Definitions (`@object-ui/types`):** - [x] `ObjectDefinition`, `ObjectDefinitionRelationship`, `ObjectManagerSchema` - [x] `DesignerFieldType` (27 types), `DesignerFieldOption`, `DesignerValidationRule` diff --git a/apps/console/src/__tests__/MetadataService.test.ts b/apps/console/src/__tests__/MetadataService.test.ts new file mode 100644 index 000000000..73a72c945 --- /dev/null +++ b/apps/console/src/__tests__/MetadataService.test.ts @@ -0,0 +1,247 @@ +/** + * MetadataService unit tests + * + * Tests the service layer that wraps ObjectStack metadata API calls + * for object and field CRUD operations. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MetadataService } from '../services/MetadataService'; +import type { ObjectDefinition, DesignerFieldDefinition } from '@object-ui/types'; + +// --------------------------------------------------------------------------- +// Mock adapter / client +// --------------------------------------------------------------------------- + +function createMockAdapter() { + const mockClient = { + meta: { + saveItem: vi.fn().mockResolvedValue({}), + getItem: vi.fn().mockResolvedValue({ item: { name: 'account', fields: [] } }), + }, + }; + + const adapter = { + getClient: vi.fn().mockReturnValue(mockClient), + invalidateCache: vi.fn(), + } as any; + + return { adapter, mockClient }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeObject(overrides: Partial = {}): ObjectDefinition { + return { + id: 'account', + name: 'account', + label: 'Accounts', + fieldCount: 3, + sortOrder: 0, + isSystem: false, + enabled: true, + ...overrides, + }; +} + +function makeField(overrides: Partial = {}): DesignerFieldDefinition { + return { + id: 'name', + name: 'name', + label: 'Name', + type: 'text', + sortOrder: 0, + required: false, + unique: false, + readonly: false, + hidden: false, + externalId: false, + trackHistory: false, + indexed: false, + isSystem: false, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests: saveObject +// --------------------------------------------------------------------------- + +describe('MetadataService', () => { + let service: MetadataService; + let mockClient: ReturnType['mockClient']; + let adapter: ReturnType['adapter']; + + beforeEach(() => { + ({ adapter, mockClient } = createMockAdapter()); + service = new MetadataService(adapter); + }); + + describe('saveObject', () => { + it('should call client.meta.saveItem with the correct payload', async () => { + const obj = makeObject(); + await service.saveObject(obj); + + expect(mockClient.meta.saveItem).toHaveBeenCalledWith( + 'object', + 'account', + expect.objectContaining({ name: 'account', label: 'Accounts', enabled: true }), + ); + }); + + it('should invalidate cache after save', async () => { + await service.saveObject(makeObject()); + expect(adapter.invalidateCache).toHaveBeenCalledWith('object:account'); + }); + + it('should propagate API errors', async () => { + mockClient.meta.saveItem.mockRejectedValueOnce(new Error('Network error')); + await expect(service.saveObject(makeObject())).rejects.toThrow('Network error'); + }); + }); + + // ------------------------------------------------------------------------- + // Tests: deleteObject + // ------------------------------------------------------------------------- + + describe('deleteObject', () => { + it('should save the object as disabled with _deleted flag', async () => { + await service.deleteObject('account'); + + expect(mockClient.meta.saveItem).toHaveBeenCalledWith( + 'object', + 'account', + expect.objectContaining({ name: 'account', enabled: false, _deleted: true }), + ); + }); + + it('should invalidate cache after delete', async () => { + await service.deleteObject('account'); + expect(adapter.invalidateCache).toHaveBeenCalledWith('object:account'); + }); + }); + + // ------------------------------------------------------------------------- + // Tests: saveFields + // ------------------------------------------------------------------------- + + describe('saveFields', () => { + it('should fetch existing object and merge fields', async () => { + const fields = [makeField(), makeField({ id: 'email', name: 'email', label: 'Email', type: 'email' })]; + await service.saveFields('account', fields); + + expect(mockClient.meta.getItem).toHaveBeenCalledWith('object', 'account'); + expect(mockClient.meta.saveItem).toHaveBeenCalledWith( + 'object', + 'account', + expect.objectContaining({ + name: 'account', + fields: expect.arrayContaining([ + expect.objectContaining({ name: 'name', type: 'text' }), + expect.objectContaining({ name: 'email', type: 'email' }), + ]), + }), + ); + }); + + it('should proceed even if fetching existing object fails', async () => { + mockClient.meta.getItem.mockRejectedValueOnce(new Error('Not found')); + const fields = [makeField()]; + await service.saveFields('new_object', fields); + + expect(mockClient.meta.saveItem).toHaveBeenCalledWith( + 'object', + 'new_object', + expect.objectContaining({ + name: 'new_object', + fields: [expect.objectContaining({ name: 'name' })], + }), + ); + }); + + it('should invalidate cache after saving fields', async () => { + await service.saveFields('account', [makeField()]); + expect(adapter.invalidateCache).toHaveBeenCalledWith('object:account'); + }); + }); + + // ------------------------------------------------------------------------- + // Tests: diffObjects (static) + // ------------------------------------------------------------------------- + + describe('diffObjects', () => { + it('should detect a created object', () => { + const prev = [makeObject()]; + const newObj = makeObject({ id: 'contact', name: 'contact', label: 'Contacts' }); + const next = [...prev, newObj]; + + const result = MetadataService.diffObjects(prev, next); + expect(result).toEqual({ type: 'create', object: newObj }); + }); + + it('should detect a deleted object', () => { + const obj1 = makeObject(); + const obj2 = makeObject({ id: 'contact', name: 'contact', label: 'Contacts' }); + const prev = [obj1, obj2]; + const next = [obj1]; + + const result = MetadataService.diffObjects(prev, next); + expect(result).toEqual({ type: 'delete', object: obj2 }); + }); + + it('should detect an updated object', () => { + const prev = [makeObject()]; + const updated = makeObject({ label: 'Updated Accounts' }); + const next = [updated]; + + const result = MetadataService.diffObjects(prev, next); + expect(result).toEqual({ type: 'update', object: updated }); + }); + + it('should return null when arrays are identical', () => { + const objs = [makeObject()]; + expect(MetadataService.diffObjects(objs, objs)).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // Tests: diffFields (static) + // ------------------------------------------------------------------------- + + describe('diffFields', () => { + it('should detect a created field', () => { + const prev = [makeField()]; + const newField = makeField({ id: 'email', name: 'email', label: 'Email' }); + const next = [...prev, newField]; + + const result = MetadataService.diffFields(prev, next); + expect(result).toEqual({ type: 'create', field: newField }); + }); + + it('should detect a deleted field', () => { + const f1 = makeField(); + const f2 = makeField({ id: 'email', name: 'email', label: 'Email' }); + const prev = [f1, f2]; + const next = [f1]; + + const result = MetadataService.diffFields(prev, next); + expect(result).toEqual({ type: 'delete', field: f2 }); + }); + + it('should detect an updated field', () => { + const prev = [makeField()]; + const updated = makeField({ label: 'Full Name' }); + const next = [updated]; + + const result = MetadataService.diffFields(prev, next); + expect(result).toEqual({ type: 'update', field: updated }); + }); + + it('should return null when arrays are identical', () => { + const fields = [makeField()]; + expect(MetadataService.diffFields(fields, fields)).toBeNull(); + }); + }); +}); diff --git a/apps/console/src/__tests__/ObjectManagerPage.test.tsx b/apps/console/src/__tests__/ObjectManagerPage.test.tsx index db09db0aa..5e8cdff55 100644 --- a/apps/console/src/__tests__/ObjectManagerPage.test.tsx +++ b/apps/console/src/__tests__/ObjectManagerPage.test.tsx @@ -3,7 +3,8 @@ * * 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. + * Covers list view, detail view with URL-based navigation, field management, + * and API integration via MetadataService. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -11,6 +12,15 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { ObjectManagerPage } from '../pages/system/ObjectManagerPage'; +// --------------------------------------------------------------------------- +// Shared mock state +// --------------------------------------------------------------------------- + +const mockRefresh = vi.fn().mockResolvedValue(undefined); +const mockSaveItem = vi.fn().mockResolvedValue({}); +const mockGetItem = vi.fn().mockResolvedValue({ item: { name: 'account', fields: [] } }); +const mockInvalidateCache = vi.fn(); + // Mock MetadataProvider vi.mock('../context/MetadataProvider', () => ({ useMetadata: () => ({ @@ -51,7 +61,20 @@ vi.mock('../context/MetadataProvider', () => ({ ], }, ], - refresh: vi.fn(), + refresh: mockRefresh, + }), +})); + +// Mock AdapterProvider (useAdapter) – provides adapter to useMetadataService +vi.mock('../context/AdapterProvider', () => ({ + useAdapter: () => ({ + getClient: () => ({ + meta: { + saveItem: mockSaveItem, + getItem: mockGetItem, + }, + }), + invalidateCache: mockInvalidateCache, }), })); @@ -172,4 +195,24 @@ describe('ObjectManagerPage', () => { }); }); }); + + // ------------------------------------------------------------------------- + // API Integration tests + // ------------------------------------------------------------------------- + + describe('API Integration', () => { + it('should have MetadataService available (adapter mock is wired)', () => { + // Rendering the page should not crash even though useMetadataService + // depends on useAdapter — the mock above provides a valid adapter. + renderPage(); + expect(screen.getByTestId('object-manager-page')).toBeDefined(); + }); + + it('should render detail view with MetadataService props', () => { + renderPage('/system/objects/account'); + expect(screen.getByTestId('object-detail-view')).toBeDefined(); + // The FieldDesigner should be rendered (service is passed through) + expect(screen.getByTestId('field-designer')).toBeDefined(); + }); + }); }); diff --git a/apps/console/src/hooks/useMetadataService.ts b/apps/console/src/hooks/useMetadataService.ts new file mode 100644 index 000000000..f447d76b9 --- /dev/null +++ b/apps/console/src/hooks/useMetadataService.ts @@ -0,0 +1,20 @@ +/** + * useMetadataService + * + * React hook that creates a memoised `MetadataService` instance from the + * current `ObjectStackAdapter` (via `useAdapter`). + * + * Returns `null` when the adapter is not yet available (e.g. while + * the connection is being established). + * + * @module hooks/useMetadataService + */ + +import { useMemo } from 'react'; +import { useAdapter } from '../context/AdapterProvider'; +import { MetadataService } from '../services/MetadataService'; + +export function useMetadataService(): MetadataService | null { + const adapter = useAdapter(); + return useMemo(() => (adapter ? new MetadataService(adapter) : null), [adapter]); +} diff --git a/apps/console/src/pages/system/ObjectManagerPage.tsx b/apps/console/src/pages/system/ObjectManagerPage.tsx index b5c77da88..0d592249c 100644 --- a/apps/console/src/pages/system/ObjectManagerPage.tsx +++ b/apps/console/src/pages/system/ObjectManagerPage.tsx @@ -5,19 +5,24 @@ * Integrates both ObjectManager (object list/CRUD) and FieldDesigner (field * configuration wizard) from @object-ui/plugin-designer. * + * All object and field mutations are persisted to the backend via the + * MetadataService (optimistic update → API call → rollback on failure). + * * Routes: * /system/objects → Object list (ObjectManager) * /system/objects/:objectName → Object detail with field management (FieldDesigner) */ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useRef } 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 { ArrowLeft, Database, Settings2, Link2, Loader2 } from 'lucide-react'; import { ObjectManager, FieldDesigner } from '@object-ui/plugin-designer'; import type { ObjectDefinition, DesignerFieldDefinition, DesignerFieldType } from '@object-ui/types'; import { toast } from 'sonner'; import { useMetadata } from '../../context/MetadataProvider'; +import { useMetadataService } from '../../hooks/useMetadataService'; +import { MetadataService } from '../../services/MetadataService'; /** Loose shape of a metadata object definition from the ObjectStack API. */ interface MetadataObject { @@ -137,20 +142,53 @@ interface ObjectDetailViewProps { object: ObjectDefinition; metadataObject: MetadataObject | undefined; onBack: () => void; + metadataService: MetadataService | null; + onRefresh: () => Promise; } -function ObjectDetailView({ object, metadataObject, onBack }: ObjectDetailViewProps) { +function ObjectDetailView({ object, metadataObject, onBack, metadataService, onRefresh }: 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 [saving, setSaving] = useState(false); const displayFields = localFields ?? fields; + const prevFieldsRef = useRef(displayFields); + + const handleFieldsChange = useCallback(async (updated: DesignerFieldDefinition[]) => { + const previous = prevFieldsRef.current; - const handleFieldsChange = useCallback((updated: DesignerFieldDefinition[]) => { + // Optimistic update setLocalFields(updated); - toast.success('Field configuration updated'); - }, []); + prevFieldsRef.current = updated; + + if (!metadataService) { + toast.error('Service unavailable — changes saved locally only'); + return; + } + + const diff = MetadataService.diffFields(previous, updated); + const actionLabel = diff + ? diff.type === 'create' ? `Field "${diff.field.label || diff.field.name}" created` + : diff.type === 'update' ? `Field "${diff.field.label || diff.field.name}" updated` + : `Field "${diff.field.label || diff.field.name}" deleted` + : 'Field configuration updated'; + + setSaving(true); + try { + await metadataService.saveFields(object.name, updated); + await onRefresh(); + toast.success(actionLabel); + } catch (err: any) { + // Rollback on failure + setLocalFields(previous); + prevFieldsRef.current = previous; + toast.error(err?.message || 'Failed to save field changes'); + } finally { + setSaving(false); + } + }, [metadataService, object.name, onRefresh]); return (
@@ -247,6 +285,12 @@ function ObjectDetailView({ object, metadataObject, onBack }: ObjectDetailViewPr {/* Field Management Section */}
+ {saving && ( +
+ + Saving field changes… +
+ )} ( @@ -273,9 +318,11 @@ export function ObjectManagerPage() { [metadataObjects] ); - // State for local object edits + // State for local object edits and saving indicator const [localObjects, setLocalObjects] = useState(null); + const [saving, setSaving] = useState(false); const displayObjects = localObjects ?? objects; + const prevObjectsRef = useRef(displayObjects); // Find selected object from URL param const selectedObject = useMemo(() => { @@ -299,10 +346,53 @@ export function ObjectManagerPage() { navigate(basePath); }, [navigate, basePath]); - const handleObjectsChange = useCallback((updated: ObjectDefinition[]) => { + const handleObjectsChange = useCallback(async (updated: ObjectDefinition[]) => { + const previous = prevObjectsRef.current; + + // Optimistic update setLocalObjects(updated); - toast.success('Object definitions updated'); - }, []); + prevObjectsRef.current = updated; + + if (!metadataService) { + toast.error('Service unavailable — changes saved locally only'); + return; + } + + const diff = MetadataService.diffObjects(previous, updated); + + setSaving(true); + try { + if (diff) { + if (diff.type === 'delete') { + await metadataService.deleteObject(diff.object.name); + } else { + // create or update — saveItem is an upsert + await metadataService.saveObject(diff.object); + } + } else { + // Multiple changes or reorder — save all objects + for (const obj of updated) { + await metadataService.saveObject(obj); + } + } + + await refresh(); + + const actionLabel = diff + ? diff.type === 'create' ? `Object "${diff.object.label || diff.object.name}" created` + : diff.type === 'update' ? `Object "${diff.object.label || diff.object.name}" updated` + : `Object "${diff.object.label || diff.object.name}" deleted` + : 'Object definitions updated'; + toast.success(actionLabel); + } catch (err: any) { + // Rollback on failure + setLocalObjects(previous); + prevObjectsRef.current = previous; + toast.error(err?.message || 'Failed to save object changes'); + } finally { + setSaving(false); + } + }, [metadataService, refresh]); // Detail view mode: show object detail + FieldDesigner if (selectedObject) { @@ -312,6 +402,8 @@ export function ObjectManagerPage() { object={selectedObject} metadataObject={selectedMetadataObject} onBack={handleBackToList} + metadataService={metadataService} + onRefresh={refresh} />
); @@ -337,6 +429,14 @@ export function ObjectManagerPage() {
+ {/* Saving indicator */} + {saving && ( +
+ + Saving object changes… +
+ )} + {/* Content */} ; +} + +/** Shape written to the metadata API for a field definition. */ +export interface FieldMetadataPayload { + name: string; + label?: string; + type: string; + group?: string; + description?: string; + required?: boolean; + unique?: boolean; + readonly?: boolean; + hidden?: boolean; + defaultValue?: string; + placeholder?: string; + options?: Array<{ label: string; value: string; color?: string }>; + externalId?: boolean; + trackHistory?: boolean; + indexed?: boolean; + referenceTo?: string; + formula?: string; + sortOrder?: number; +} + +// --------------------------------------------------------------------------- +// Converters: UI types → API payloads +// --------------------------------------------------------------------------- + +/** Convert an `ObjectDefinition` (UI) to the API payload shape. */ +function toObjectPayload(obj: ObjectDefinition, fields?: FieldMetadataPayload[]): ObjectMetadataPayload { + return { + name: obj.name, + label: obj.label, + pluralLabel: obj.pluralLabel, + description: obj.description, + icon: obj.icon, + group: obj.group, + sortOrder: obj.sortOrder, + enabled: obj.enabled, + fields, + relationships: obj.relationships, + }; +} + +/** Convert a `DesignerFieldDefinition` (UI) to the API payload shape. */ +function toFieldPayload(field: DesignerFieldDefinition): FieldMetadataPayload { + return { + name: field.name, + label: field.label, + type: field.type, + group: field.group, + description: field.description, + required: field.required, + unique: field.unique, + readonly: field.readonly, + hidden: field.hidden, + defaultValue: field.defaultValue, + placeholder: field.placeholder, + options: field.options, + externalId: field.externalId, + trackHistory: field.trackHistory, + indexed: field.indexed, + referenceTo: field.referenceTo, + formula: field.formula, + sortOrder: field.sortOrder, + }; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +export class MetadataService { + constructor(private adapter: ObjectStackAdapter) {} + + // ----------------------------------------------------------------------- + // Object operations + // ----------------------------------------------------------------------- + + /** + * Persist an object definition to the backend. + * Works for both create and update (the API is an upsert). + */ + async saveObject(obj: ObjectDefinition, existingFields?: FieldMetadataPayload[]): Promise { + const client = this.adapter.getClient(); + const payload = toObjectPayload(obj, existingFields); + await client.meta.saveItem('object', obj.name, payload); + this.adapter.invalidateCache(`object:${obj.name}`); + } + + /** + * Delete an object definition from the backend. + * + * NOTE: The ObjectStack metadata API currently exposes `saveItem` but no + * dedicated `deleteItem`. We persist the object with `enabled: false` so + * the intent is recorded and the object is hidden from active use. + * A full hard-delete can be added once the backend supports it. + */ + async deleteObject(objectName: string): Promise { + const client = this.adapter.getClient(); + await client.meta.saveItem('object', objectName, { name: objectName, enabled: false, _deleted: true }); + this.adapter.invalidateCache(`object:${objectName}`); + } + + // ----------------------------------------------------------------------- + // Field operations (fields are stored as part of their parent object) + // ----------------------------------------------------------------------- + + /** + * Persist updated fields for an object. + * + * Fetches the current object metadata, replaces its `fields` array with the + * provided designer fields, and saves the whole object back. + */ + async saveFields(objectName: string, fields: DesignerFieldDefinition[]): Promise { + const client = this.adapter.getClient(); + + // Fetch current object metadata to preserve non-field properties + let existingObject: Record = {}; + try { + const raw: any = await client.meta.getItem('object', objectName); + existingObject = raw?.item ?? raw ?? {}; + } catch { + // Object may not exist yet on the backend; proceed with fields-only save + } + + const updatedObject = { + ...existingObject, + name: objectName, + fields: fields.map(toFieldPayload), + }; + + await client.meta.saveItem('object', objectName, updatedObject); + this.adapter.invalidateCache(`object:${objectName}`); + } + + // ----------------------------------------------------------------------- + // Diff helpers — determine what changed between two arrays + // ----------------------------------------------------------------------- + + /** + * Detect changes between previous and next object arrays. + * + * Returns the single object that was created, updated, or deleted. + * If multiple objects changed simultaneously the function returns `null` + * (callers should treat this as a bulk save of the entire array). + */ + static diffObjects( + prev: ObjectDefinition[], + next: ObjectDefinition[], + ): { type: 'create' | 'update' | 'delete'; object: ObjectDefinition } | null { + const prevMap = new Map(prev.map((o) => [o.id, o])); + const nextMap = new Map(next.map((o) => [o.id, o])); + + // Detect creation (exists in next but not prev) + for (const [id, obj] of nextMap) { + if (!prevMap.has(id)) return { type: 'create', object: obj }; + } + + // Detect deletion (exists in prev but not next) + for (const [id, obj] of prevMap) { + if (!nextMap.has(id)) return { type: 'delete', object: obj }; + } + + // Detect update (same id but different content) + for (const [id, nextObj] of nextMap) { + const prevObj = prevMap.get(id); + if (prevObj && JSON.stringify(prevObj) !== JSON.stringify(nextObj)) { + return { type: 'update', object: nextObj }; + } + } + + return null; + } + + /** + * Detect changes between previous and next field arrays. + */ + static diffFields( + prev: DesignerFieldDefinition[], + next: DesignerFieldDefinition[], + ): { type: 'create' | 'update' | 'delete'; field: DesignerFieldDefinition } | null { + const prevMap = new Map(prev.map((f) => [f.id, f])); + const nextMap = new Map(next.map((f) => [f.id, f])); + + for (const [id, field] of nextMap) { + if (!prevMap.has(id)) return { type: 'create', field }; + } + + for (const [id, field] of prevMap) { + if (!nextMap.has(id)) return { type: 'delete', field }; + } + + for (const [id, nextField] of nextMap) { + const prevField = prevMap.get(id); + if (prevField && JSON.stringify(prevField) !== JSON.stringify(nextField)) { + return { type: 'update', field: nextField }; + } + } + + return null; + } +} diff --git a/packages/plugin-designer/src/FieldDesigner.tsx b/packages/plugin-designer/src/FieldDesigner.tsx index 0cf7fdc1e..c55f1f47e 100644 --- a/packages/plugin-designer/src/FieldDesigner.tsx +++ b/packages/plugin-designer/src/FieldDesigner.tsx @@ -10,24 +10,19 @@ * 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). + * Uses standard ObjectGrid for the list view and ModalForm for + * create/edit dialogs with type-specific field properties. */ import React, { useState, useCallback, useMemo } from 'react'; -import type { DesignerFieldDefinition, DesignerFieldType, DesignerFieldOption, DesignerValidationRule } from '@object-ui/types'; +import type { DesignerFieldDefinition, DesignerFieldType } from '@object-ui/types'; import type { ObjectGridSchema, ListColumn } from '@object-ui/types'; import { ObjectGrid } from '@object-ui/plugin-grid'; +import { ModalForm } from '@object-ui/plugin-form'; +import type { ModalFormSchema } from '@object-ui/plugin-form'; import { ValueDataSource } from '@object-ui/core'; import { - Plus, - Trash2, - ChevronDown, - ChevronUp, Columns3, - Settings2, - Lock, Hash, Type, Calendar, @@ -44,7 +39,7 @@ import { MapPin, Star, SlidersHorizontal, - X, + Lock, } from 'lucide-react'; import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; @@ -109,413 +104,10 @@ const FIELD_TYPE_META: Record 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 */} -
- -