Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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` |
Expand Down
117 changes: 113 additions & 4 deletions packages/plugin-designer/src/DataModelDesigner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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[],
Expand Down Expand Up @@ -88,7 +96,7 @@ export function DataModelDesigner({
const containerRef = useRef<HTMLDivElement>(null);

// --- Undo/Redo ---
const undoRedo = useUndoRedo<DesignerState>(
const undoRedo = useDesignerHistory<DesignerState>(
{ entities: initialEntities, relationships: initialRelationships },
{ maxHistory: 50 },
);
Expand Down Expand Up @@ -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<string | null>(null);
const [editingEntityLabelValue, setEditingEntityLabelValue] = useState('');

// --- Drag state ---
const dragRef = useRef<{ entityId: string; offsetX: number; offsetY: number } | null>(null);

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -411,6 +475,7 @@ export function DataModelDesigner({
} else if (e.key === 'Escape') {
multiSelect.clearSelection();
setEditingField(null);
setEditingEntityLabel(null);
}
};
el.addEventListener('keydown', handleKeyDown);
Expand Down Expand Up @@ -654,7 +719,34 @@ export function DataModelDesigner({
style={{ backgroundColor: entity.color ?? 'hsl(var(--primary) / 0.1)' }}
>
<Database className="h-3.5 w-3.5" />
<span className="truncate">{entity.label}</span>
{editingEntityLabel === entity.id ? (
<input
type="text"
value={editingEntityLabelValue}
onChange={(e) => 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}`}
/>
) : (
<span
className="truncate cursor-text"
onDoubleClick={(e) => {
e.stopPropagation();
handleStartEntityLabelEdit(entity.id, entity.label);
}}
title="Double-click to edit label"
data-testid={`entity-label-${entity.id}`}
>
{entity.label}
</span>
)}
{!readOnly && (
<button
onClick={(e) => {
Expand Down Expand Up @@ -709,7 +801,24 @@ export function DataModelDesigner({
{field.name}
</span>
)}
<span className="text-muted-foreground ml-auto">{field.type}</span>
{!readOnly ? (
<select
value={field.type}
onChange={(e) => {
e.stopPropagation();
handleFieldTypeChange(entity.id, fieldIndex, e.target.value);
}}
onClick={(e) => e.stopPropagation()}
className="text-xs text-muted-foreground ml-auto bg-transparent border-none focus:ring-1 focus:ring-primary rounded cursor-pointer p-0"
data-testid={`field-type-${entity.id}-${fieldIndex}`}
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The field type has no associated label (it’s visually inline), so screen readers won’t have an accessible name for the control. Add an aria-label/aria-labelledby (e.g., include the field name) so the dropdown is discoverable and understandable via assistive tech. data-testid={`field-type-${entity.id}-${fieldIndex}`} aria-label={`Field type for ${field.name}`}

Copilot uses AI. Check for mistakes.
>
{DATA_MODEL_FIELD_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
) : (
<span className="text-muted-foreground ml-auto">{field.type}</span>
)}
{field.required && <span className="text-destructive">*</span>}
</div>
))}
Expand Down
78 changes: 68 additions & 10 deletions packages/plugin-designer/src/ViewDesigner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
} 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 { useClipboard } from './hooks/useClipboard';
import { ConfirmDialog } from './components/ConfirmDialog';
Expand Down Expand Up @@ -164,6 +164,32 @@ const FILTER_OPERATORS = [
{ value: 'ne', label: '!=' },
];

/** Supported field data types for columns */
const DESIGNER_FIELD_TYPES = [
{ value: 'text', label: 'Text' },
{ value: 'number', label: 'Number' },
{ value: 'currency', label: 'Currency' },
{ value: 'percent', label: 'Percent' },
{ value: 'date', label: 'Date' },
{ value: 'datetime', label: 'Date & Time' },
{ value: 'boolean', label: 'Checkbox' },
{ value: 'select', label: 'Select' },
{ value: 'multiselect', label: 'Multi-Select' },
{ value: 'email', label: 'Email' },
{ value: 'url', label: 'URL' },
{ value: 'phone', label: 'Phone' },
{ value: 'textarea', label: 'Long Text' },
{ value: 'lookup', label: 'Lookup' },
{ value: 'attachment', label: 'Attachment' },
{ value: 'formula', label: 'Formula' },
{ value: 'autonumber', label: 'Auto Number' },
{ value: 'rating', label: 'Rating' },
];

/** Column width constraints */
const COLUMN_WIDTH_MIN = 50;
const COLUMN_WIDTH_MAX = 1000;

/**
* Visual designer for creating and editing list views.
* Provides a 3-panel layout:
Expand All @@ -189,7 +215,7 @@ export function ViewDesigner({
const containerRef = useRef<HTMLDivElement>(null);

// --- Undo/Redo ---
const history = useUndoRedo<ViewDesignerState>({
const history = useDesignerHistory<ViewDesignerState>({
columns: initialColumns,
filters: initialFilters,
sort: initialSort,
Expand Down Expand Up @@ -375,6 +401,11 @@ export function ViewDesigner({

if (isInput) return;

if (ctrl && e.key === 's' && !readOnly) {
e.preventDefault();
handleSave();
return;
}
Comment on lines +404 to +408
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ctrl/Cmd+S handling checks e.key === 's' only. With Caps Lock or some layouts, KeyboardEvent.key can be uppercase ('S'), so the shortcut may not fire reliably. Consider normalizing via e.key.toLowerCase() === 's' (or checking code === 'KeyS') before triggering save.

Copilot uses AI. Check for mistakes.
if (ctrl && e.key === 'z' && !e.shiftKey && !readOnly) {
e.preventDefault();
history.undo();
Expand All @@ -399,7 +430,7 @@ export function ViewDesigner({

el.addEventListener('keydown', handleKeyDown);
return () => el.removeEventListener('keydown', handleKeyDown);
}, [readOnly, history, handleCopyColumn, handlePasteColumn]);
}, [readOnly, history, handleCopyColumn, handlePasteColumn, handleSave]);

return (
<div
Expand Down Expand Up @@ -718,6 +749,22 @@ export function ViewDesigner({
data-testid="column-label-input"
/>
</div>
{/* Field type selector */}
<div>
<label className="text-xs text-muted-foreground">Field Type</label>
<select
value={availableFields.find((f) => f.name === columns[selectedColumnIndex].field)?.type ?? 'text'}
disabled
className="w-full px-2 py-1 text-sm border rounded bg-muted/50 mt-1"
data-testid="column-field-type"
>
{DESIGNER_FIELD_TYPES.map((ft) => (
<option key={ft.value} value={ft.value}>
{ft.label}
</option>
))}
</select>
</div>
<div>
<label className="text-xs text-muted-foreground">{LABELS.widthField}</label>
<input
Expand All @@ -727,19 +774,30 @@ export function ViewDesigner({
if (readOnly) return;
const idx = selectedColumnIndex;
const val = e.target.value;
pushState({
columns: columns.map((c, i) =>
i === idx
? { ...c, width: /^\d+$/.test(val) ? Number(val) : val }
: c,
),
});
if (val === '' || val === 'auto') {
pushState({
columns: columns.map((c, i) => (i === idx ? { ...c, width: val === 'auto' ? 'auto' : undefined } : c)),
});
return;
}
if (/^\d+$/.test(val)) {
const num = Number(val);
const clamped = Math.max(COLUMN_WIDTH_MIN, Math.min(COLUMN_WIDTH_MAX, num));
pushState({
columns: columns.map((c, i) => (i === idx ? { ...c, width: clamped } : c)),
});
return;
}
// Reject non-numeric, non-auto values
}}
Comment on lines +779 to 792
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Column width is clamped on every keystroke. Because the input is controlled, typing a value that starts below the min (e.g., trying to enter "100" by typing "1") will immediately clamp to 50 and makes it impossible to enter many valid multi-digit widths. Consider keeping a local string state for the width input and only validating/clamping on blur or on save (and/or allow intermediate values < min while editing).

Suggested change
columns: columns.map((c, i) => (i === idx ? { ...c, width: val === 'auto' ? 'auto' : undefined } : c)),
});
return;
}
if (/^\d+$/.test(val)) {
const num = Number(val);
const clamped = Math.max(COLUMN_WIDTH_MIN, Math.min(COLUMN_WIDTH_MAX, num));
pushState({
columns: columns.map((c, i) => (i === idx ? { ...c, width: clamped } : c)),
});
return;
}
// Reject non-numeric, non-auto values
}}
columns: columns.map((c, i) =>
i === idx ? { ...c, width: val === 'auto' ? 'auto' : undefined } : c,
),
});
return;
}
if (/^\d+$/.test(val)) {
const num = Number(val);
// Allow intermediate numeric values without clamping on each keystroke.
pushState({
columns: columns.map((c, i) => (i === idx ? { ...c, width: num } : c)),
});
return;
}
// Reject non-numeric, non-auto values
}}
onBlur={() => {
if (readOnly) return;
const idx = selectedColumnIndex;
const current = columns[idx]?.width;
if (typeof current === 'number') {
const clamped = Math.max(COLUMN_WIDTH_MIN, Math.min(COLUMN_WIDTH_MAX, current));
if (clamped !== current) {
pushState({
columns: columns.map((c, i) => (i === idx ? { ...c, width: clamped } : c)),
});
}
}
}}

Copilot uses AI. Check for mistakes.
placeholder={LABELS.widthPlaceholder}
className="w-full px-2 py-1 text-sm border rounded bg-background mt-1"
readOnly={readOnly}
data-testid="column-width-input"
/>
<span className="text-[10px] text-muted-foreground mt-0.5 block">
{COLUMN_WIDTH_MIN}–{COLUMN_WIDTH_MAX}px or &quot;auto&quot;
</span>
</div>
<div className="text-xs text-muted-foreground">
{LABELS.fieldLabel} <span className="font-mono">{columns[selectedColumnIndex].field}</span>
Expand Down
Loading