From 298abc977a497a302903bb02219bc4bef77d6bec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:03:34 +0000 Subject: [PATCH 1/3] Initial plan From 7de927402ec7e62ac35c395538b0ab92fedf6fbf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:12:57 +0000 Subject: [PATCH 2/3] feat(plugin-designer): implement P1.1 Designer Interaction features - Add useDesignerHistory hook (command pattern wrapper) - ViewDesigner: Ctrl+S/Cmd+S save shortcut, field type selector, width validation (min/max) - DataModelDesigner: inline entity label editing, field type selector dropdown - Wire useDesignerHistory to both designers - Add comprehensive tests (63 total, 33 new) - Update ROADMAP.md with completed P1.1 items Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 22 +- .../plugin-designer/src/DataModelDesigner.tsx | 117 ++++++- packages/plugin-designer/src/ViewDesigner.tsx | 82 ++++- .../src/__tests__/DataModelDesigner.test.tsx | 285 ++++++++++++++++++ .../src/__tests__/ViewDesigner.test.tsx | 165 ++++++++++ .../src/__tests__/useDesignerHistory.test.ts | 88 ++++++ packages/plugin-designer/src/hooks/index.ts | 3 + .../src/hooks/useDesignerHistory.ts | 37 +++ packages/plugin-designer/src/index.tsx | 1 + 9 files changed, 775 insertions(+), 25 deletions(-) create mode 100644 packages/plugin-designer/src/__tests__/DataModelDesigner.test.tsx create mode 100644 packages/plugin-designer/src/__tests__/useDesignerHistory.test.ts create mode 100644 packages/plugin-designer/src/hooks/useDesignerHistory.ts diff --git a/ROADMAP.md b/ROADMAP.md index 787d3e4d3..555539bee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,7 +17,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind **What Remains:** The gap to **Airtable-level UX** is primarily in: 1. ~~**AppShell** — No dynamic navigation renderer from spec JSON (last P0 blocker)~~ ✅ Complete -2. **Designer Interaction** — ViewDesigner and DataModelDesigner need drag-and-drop, undo/redo +2. **Designer Interaction** — ViewDesigner and DataModelDesigner have undo/redo, field type selectors, inline editing, Ctrl+S save (column drag-to-reorder with dnd-kit pending) 3. **Console Advanced Polish** — Remaining upgrades for forms, import/export, automation, comments 4. **PWA Sync** — Background sync is simulated only @@ -47,19 +47,19 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind **ViewDesigner:** - [ ] Column drag-to-reorder via `@dnd-kit/core` (replace up/down buttons with drag handles) -- [ ] Add `Ctrl+S`/`Cmd+S` keyboard shortcut to save -- [ ] Add field type selector dropdown with icons from `DESIGNER_FIELD_TYPES` -- [ ] Column width validation (min/max/pattern check) +- [x] Add `Ctrl+S`/`Cmd+S` keyboard shortcut to save +- [x] Add field type selector dropdown with icons from `DESIGNER_FIELD_TYPES` +- [x] Column width validation (min/max/pattern check) **DataModelDesigner:** -- [ ] Entity drag-to-move on canvas -- [ ] Inline editing for entity labels (click to edit) -- [ ] Field type selector dropdown (replaces hardcoded `'text'` type) -- [ ] Confirmation dialogs for destructive actions (delete entity cascades to relationships) +- [x] Entity drag-to-move on canvas +- [x] Inline editing for entity labels (click to edit) +- [x] Field type selector dropdown (replaces hardcoded `'text'` type) +- [x] Confirmation dialogs for destructive actions (delete entity cascades to relationships) **Shared Infrastructure:** -- [ ] Implement `useDesignerHistory` hook (command pattern with undo/redo stacks) -- [ ] Wire undo/redo to ViewDesigner and DataModelDesigner +- [x] Implement `useDesignerHistory` hook (command pattern with undo/redo stacks) +- [x] Wire undo/redo to ViewDesigner and DataModelDesigner ### P1.2 Console — Forms & Data Collection @@ -205,7 +205,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind |--------|---------|-------------|--------------| | **Protocol Alignment** | ~85% | 90%+ (UI-facing) | Protocol Consistency Assessment | | **AppShell Renderer** | ✅ Complete | Sidebar + nav tree from `AppSchema` JSON | Console renders from spec JSON | -| **Designer Interaction** | Phase 1 only | ViewDesigner + DataModelDesigner drag/undo | Manual UX testing | +| **Designer Interaction** | Phase 2 (most complete) | ViewDesigner + DataModelDesigner drag/undo | Manual UX testing | | **Build Status** | 42/42 pass | 42/42 pass | `pnpm build` | | **Test Count** | 5,070+ | 5,500+ | `pnpm test` summary | | **Test Coverage** | 90%+ | 90%+ | `pnpm test:coverage` | diff --git a/packages/plugin-designer/src/DataModelDesigner.tsx b/packages/plugin-designer/src/DataModelDesigner.tsx index 1eeb5fd62..038b1174d 100644 --- a/packages/plugin-designer/src/DataModelDesigner.tsx +++ b/packages/plugin-designer/src/DataModelDesigner.tsx @@ -11,7 +11,7 @@ import type { DataModelEntity, DataModelField, DataModelRelationship, DesignerCa import { Database, Plus, Trash2, Link2, Undo2, Redo2, Grid3X3, ZoomIn, ZoomOut, RotateCcw, ChevronDown, ChevronRight, Copy, Clipboard, Users } from 'lucide-react'; import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; -import { useUndoRedo } from './hooks/useUndoRedo'; +import { useDesignerHistory } from './hooks/useDesignerHistory'; import { useConfirmDialog } from './hooks/useConfirmDialog'; import { useMultiSelect } from './hooks/useMultiSelect'; import { useClipboard } from './hooks/useClipboard'; @@ -25,6 +25,14 @@ function cn(...inputs: (string | undefined | false)[]) { return twMerge(clsx(inputs)); } +/** Supported data field types for the DataModel designer */ +const DATA_MODEL_FIELD_TYPES = [ + 'text', 'number', 'boolean', 'date', 'datetime', 'uuid', + 'email', 'url', 'phone', 'json', 'integer', 'float', + 'decimal', 'currency', 'percent', 'textarea', 'select', + 'multiselect', 'lookup', 'attachment', 'formula', 'autonumber', +] as const; + /** Arrange entities in a grid layout */ function calculateGridAutoLayout( entities: DataModelEntity[], @@ -88,7 +96,7 @@ export function DataModelDesigner({ const containerRef = useRef(null); // --- Undo/Redo --- - const undoRedo = useUndoRedo( + const undoRedo = useDesignerHistory( { entities: initialEntities, relationships: initialRelationships }, { maxHistory: 50 }, ); @@ -131,6 +139,10 @@ export function DataModelDesigner({ const [editingField, setEditingField] = useState<{ entityId: string; fieldIndex: number } | null>(null); const [editingFieldValue, setEditingFieldValue] = useState(''); + // --- Inline entity label editing --- + const [editingEntityLabel, setEditingEntityLabel] = useState(null); + const [editingEntityLabelValue, setEditingEntityLabelValue] = useState(''); + // --- Drag state --- const dragRef = useRef<{ entityId: string; offsetX: number; offsetY: number } | null>(null); @@ -305,6 +317,58 @@ export function DataModelDesigner({ setEditingField(null); }, []); + // --- Inline entity label editing --- + const handleStartEntityLabelEdit = useCallback( + (entityId: string, currentLabel: string) => { + if (readOnly) return; + setEditingEntityLabel(entityId); + setEditingEntityLabelValue(currentLabel); + }, + [readOnly], + ); + + const handleCommitEntityLabelEdit = useCallback(() => { + if (!editingEntityLabel) return; + const trimmed = editingEntityLabelValue.trim(); + if (trimmed === '') { + setEditingEntityLabel(null); + return; + } + const current = undoRedo.current; + const next: DesignerState = { + entities: current.entities.map((e) => + e.id === editingEntityLabel ? { ...e, label: trimmed } : e, + ), + relationships: current.relationships, + }; + pushState(next); + setEditingEntityLabel(null); + }, [editingEntityLabel, editingEntityLabelValue, undoRedo, pushState]); + + const handleCancelEntityLabelEdit = useCallback(() => { + setEditingEntityLabel(null); + }, []); + + // --- Field type change --- + const handleFieldTypeChange = useCallback( + (entityId: string, fieldIndex: number, newType: string) => { + if (readOnly) return; + const current = undoRedo.current; + const next: DesignerState = { + entities: current.entities.map((e) => { + if (e.id !== entityId) return e; + const fields = e.fields.map((f, i) => + i === fieldIndex ? { ...f, type: newType } : f, + ); + return { ...e, fields }; + }), + relationships: current.relationships, + }; + pushState(next); + }, + [readOnly, undoRedo, pushState], + ); + // --- Drag to reposition --- const handleDragStart = useCallback( (e: React.DragEvent, entityId: string) => { @@ -411,6 +475,7 @@ export function DataModelDesigner({ } else if (e.key === 'Escape') { multiSelect.clearSelection(); setEditingField(null); + setEditingEntityLabel(null); } }; el.addEventListener('keydown', handleKeyDown); @@ -654,7 +719,34 @@ export function DataModelDesigner({ style={{ backgroundColor: entity.color ?? 'hsl(var(--primary) / 0.1)' }} > - {entity.label} + {editingEntityLabel === entity.id ? ( + setEditingEntityLabelValue(e.target.value)} + onBlur={handleCommitEntityLabelEdit} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCommitEntityLabelEdit(); + if (e.key === 'Escape') handleCancelEntityLabelEdit(); + }} + className="text-sm font-medium px-1 py-0 border rounded bg-background w-32 focus:outline-none focus:ring-1 focus:ring-primary" + autoFocus + onClick={(e) => e.stopPropagation()} + data-testid={`entity-label-input-${entity.id}`} + /> + ) : ( + { + e.stopPropagation(); + handleStartEntityLabelEdit(entity.id, entity.label); + }} + title="Double-click to edit label" + data-testid={`entity-label-${entity.id}`} + > + {entity.label} + + )} {!readOnly && (