diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 03bfc3c66..438549b09 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -570,6 +570,13 @@ const ENGINE_STRINGS_EN: Record = { 'designer.object.iconHint': 'Lucide icon name (e.g. “building”, “users”)', 'designer.object.description': 'Description', 'designer.object.descriptionPlaceholder': 'What this object represents', + // Field ergonomics (Tier 2) + 'designer.field.defaultValue': 'Default value', + 'designer.field.defaultNone': '— None —', + 'designer.field.true': 'True', + 'designer.field.false': 'False', + 'designer.field.duplicate': 'Duplicate field', + 'designer.field.copySuffix': ' copy', }; const ENGINE_STRINGS_ZH: Record = { @@ -999,6 +1006,13 @@ const ENGINE_STRINGS_ZH: Record = { 'designer.object.iconHint': 'Lucide 图标名称(如 “building”、“users”)', 'designer.object.description': '描述', 'designer.object.descriptionPlaceholder': '该对象代表什么', + // Field ergonomics (Tier 2) + 'designer.field.defaultValue': '默认值', + 'designer.field.defaultNone': '— 无 —', + 'designer.field.true': '是', + 'designer.field.false': '否', + 'designer.field.duplicate': '复制字段', + 'designer.field.copySuffix': ' 副本', }; function pickTable( diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx new file mode 100644 index 000000000..db76155bf --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx @@ -0,0 +1,106 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; + +// The inspector loads the object list for the lookup picker; stub it. +vi.mock('../useMetadata', () => ({ + useMetadataClient: () => ({ list: vi.fn().mockResolvedValue([]) }), +})); + +import { ObjectFieldInspector } from './ObjectFieldInspector'; + +afterEach(cleanup); + +function renderField( + fields: Record>, + selectedId: string, + overrides: Record = {}, +) { + const onPatch = vi.fn(); + const onSelectionChange = vi.fn(); + const utils = render( + , + ); + return { onPatch, onSelectionChange, ...utils }; +} + +function controlFor(label: string): HTMLElement { + const lab = screen.getByText(label); + return lab.parentElement!.querySelector('input, textarea, select, [role="combobox"]') as HTMLElement; +} + +describe('ObjectFieldInspector — duplicate field', () => { + it('clones the field below itself with a unique name and selects it', () => { + const { onPatch, onSelectionChange } = renderField( + { email: { type: 'email', label: 'Email' } }, + 'email', + ); + fireEvent.click(screen.getByRole('button', { name: 'Duplicate field' })); + const patch = onPatch.mock.calls.at(-1)![0]; + expect(Object.keys(patch.fields)).toEqual(['email', 'email_copy']); + expect(patch.fields.email_copy).toMatchObject({ type: 'email', label: 'Email copy' }); + expect(onSelectionChange).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'field', id: 'email_copy' }), + ); + }); + + it('avoids name collisions when a copy already exists', () => { + const { onPatch } = renderField( + { email: { type: 'email' }, email_copy: { type: 'email' } }, + 'email', + ); + fireEvent.click(screen.getByRole('button', { name: 'Duplicate field' })); + const patch = onPatch.mock.calls.at(-1)![0]; + expect(Object.keys(patch.fields)).toContain('email_copy_2'); + }); +}); + +describe('ObjectFieldInspector — default value', () => { + it('commits a text default for a text field', () => { + const { onPatch } = renderField({ note: { type: 'text', label: 'Note' } }, 'note'); + fireEvent.change(controlFor('Default value'), { target: { value: 'hello' } }); + expect(onPatch).toHaveBeenCalledWith( + expect.objectContaining({ fields: expect.anything() }), + ); + const patch = onPatch.mock.calls.at(-1)![0]; + expect(patch.fields.note.defaultValue).toBe('hello'); + }); + + it('offers a select (not a text box) default for a boolean field', () => { + renderField({ active: { type: 'boolean', label: 'Active' } }, 'active'); + expect(screen.getByText('Default value')).toBeInTheDocument(); + // Boolean default uses a tri-state Select (— / True / False), so the + // control is a combobox rather than a free-text input. + const ctrl = controlFor('Default value'); + expect(ctrl.getAttribute('role')).toBe('combobox'); + }); + + it('omits the default-value editor for computed fields', () => { + renderField({ total: { type: 'formula', label: 'Total' } }, 'total'); + expect(screen.queryByText('Default value')).not.toBeInTheDocument(); + }); + + it('omits the default-value editor for lookup fields', () => { + renderField({ owner: { type: 'lookup', label: 'Owner' } }, 'owner'); + expect(screen.queryByText('Default value')).not.toBeInTheDocument(); + }); +}); + +describe('ObjectFieldInspector — read-only', () => { + it('hides the duplicate action when read-only', () => { + renderField({ email: { type: 'email' } }, 'email', { readOnly: true }); + expect(screen.queryByRole('button', { name: 'Duplicate field' })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index d0363822f..e43c2c797 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -37,7 +37,7 @@ import { moveArray, } from './_shared'; import { Button, Input, Label, Badge } from '@object-ui/components'; -import { Plus, X, ArrowUp, ArrowDown } from 'lucide-react'; +import { Plus, X, ArrowUp, ArrowDown, Copy } from 'lucide-react'; import { readFields, writeFields, @@ -95,6 +95,28 @@ function isTexty(type: string): boolean { return type === 'text' || type === 'textarea' || type === 'email' || type === 'url' || type === 'phone' || type === 'password'; } +type DefaultKind = 'bool' | 'number' | 'picklist' | 'text'; + +/** + * Which default-value editor (if any) fits a field type. Computed, + * relational, media and structural types have no meaningful literal + * default in this UI, so they return null (no editor rendered). + */ +function defaultValueKind(type: string): DefaultKind | null { + if (type === 'boolean' || type === 'toggle') return 'bool'; + if (type === 'number' || type === 'currency' || type === 'percent') return 'number'; + if (type === 'select' || type === 'radio') return 'picklist'; + const noDefault = [ + 'formula', 'summary', 'autonumber', + 'lookup', 'master_detail', 'tree', + 'file', 'image', 'avatar', 'video', 'audio', 'signature', 'qrcode', + 'composite', 'repeater', 'vector', + 'multiselect', 'checkboxes', 'tags', + ]; + if (noDefault.includes(type)) return null; + return 'text'; +} + function buildTypeOptions(locale?: string): Array<{ value: string; label: string }> { const zh = (locale ?? '').toLowerCase().startsWith('zh'); const cats = zh ? CATEGORY_LABEL_ZH : CATEGORY_LABEL_EN; @@ -176,6 +198,25 @@ export function ObjectFieldInspector({ onClearSelection(); }; + const duplicateField = () => { + // Clone the field below itself with a collision-free name and a + // "(copy)" label, then select the clone so it's ready to tweak. + const existing = new Set(view.entries.map((e) => e.name)); + const base = `${entry.name}_copy`; + let name = base; + let n = 1; + while (existing.has(name)) { n += 1; name = `${base}_${n}`; } + const labelStr = typeof def.label === 'string' && def.label ? def.label : ''; + const clone: FieldEntry = { + name, + def: { ...def, label: labelStr ? labelStr + tr('designer.field.copySuffix') : undefined }, + }; + const nextEntries = [...view.entries]; + nextEntries.splice(idx + 1, 0, clone); + writeView({ shape: view.shape, entries: nextEntries }); + onSelectionChange?.({ kind: 'field', id: name, label: String(clone.def.label ?? name) }); + }; + const moveTo = (toIndex: number) => { const next = { shape: view.shape, entries: moveArray(view.entries, idx, toIndex) }; writeView(next); @@ -198,12 +239,26 @@ export function ObjectFieldInspector({ /* ─── Render ─── */ const headerActions = ( - +
+ {!readOnly && ( + + )} + +
); const footer = ( @@ -270,6 +325,16 @@ export function ObjectFieldInspector({ disabled={readOnly} rows={2} /> + {defaultValueKind(type) && ( + patchDef({ defaultValue: v })} + disabled={readOnly} + locale={locale} + /> + )} {/* Type-specific */} @@ -425,6 +490,80 @@ function Section({ title, children }: { title: string; children: React.ReactNode ); } +/** Type-aware default-value editor. Stores the literal on `Field.defaultValue`. */ +function DefaultValueField({ + kind, + value, + options, + onCommit, + disabled, + locale, +}: { + kind: DefaultKind; + value: unknown; + options: Option[]; + onCommit: (v: unknown) => void; + disabled?: boolean; + locale?: string; +}) { + const label = t('designer.field.defaultValue', locale); + const none = t('designer.field.defaultNone', locale); + + if (kind === 'bool') { + const cur = value === true ? 'true' : value === false ? 'false' : ''; + return ( + onCommit(v === '' ? undefined : v === 'true')} + disabled={disabled} + /> + ); + } + + if (kind === 'number') { + return ( + onCommit(v)} + disabled={disabled} + /> + ); + } + + if (kind === 'picklist') { + return ( + o.value) + .map((o) => ({ value: o.value, label: o.label || o.value })), + ]} + onCommit={(v) => onCommit(v || undefined)} + disabled={disabled} + /> + ); + } + + return ( + onCommit(v || undefined)} + disabled={disabled} + /> + ); +} + function TextareaField({ label, value, diff --git a/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.test.tsx index 3c589fe36..0bc7bdc69 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.test.tsx @@ -144,3 +144,43 @@ describe('ObjectFormCanvas — empty state', () => { expect(screen.queryByText('fields')).not.toBeInTheDocument(); }); }); + +describe('ObjectFormCanvas — keyboard reorder (Alt+↑/↓)', () => { + const draft = { + name: 'x', + fields: { + alpha: { type: 'text', label: 'Alpha' }, + beta: { type: 'text', label: 'Beta' }, + }, + }; + const rowFor = (label: string) => screen.getByText(label).closest('[role="button"]')!; + + it('Alt+ArrowDown swaps a field with the next one', () => { + const onPatch = vi.fn(); + render(); + fireEvent.keyDown(rowFor('Alpha'), { key: 'ArrowDown', altKey: true }); + const patch = onPatch.mock.calls.at(-1)![0]; + expect(Object.keys(patch.fields)).toEqual(['beta', 'alpha']); + }); + + it('Alt+ArrowUp on the first field is a no-op', () => { + const onPatch = vi.fn(); + render(); + fireEvent.keyDown(rowFor('Alpha'), { key: 'ArrowUp', altKey: true }); + expect(onPatch).not.toHaveBeenCalled(); + }); + + it('plain ArrowDown (no Alt) does not reorder', () => { + const onPatch = vi.fn(); + render(); + fireEvent.keyDown(rowFor('Alpha'), { key: 'ArrowDown' }); + expect(onPatch).not.toHaveBeenCalled(); + }); + + it('is inert when read-only (no onPatch)', () => { + render(); + // No throw, and the row is not draggable/reorderable. + fireEvent.keyDown(rowFor('Alpha'), { key: 'ArrowDown', altKey: true }); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.tsx b/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.tsx index 62008b293..f46413fef 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.tsx @@ -248,6 +248,31 @@ export function ObjectFormCanvas({ [onPatch, view], ); + // Keyboard reorder (Alt+↑/↓): swap a field with its nearest neighbour + // in the SAME group so a focused row moves predictably within its + // section without ever changing groups. + const moveFieldByOffset = React.useCallback( + (name: string, dir: -1 | 1) => { + if (!onPatch) return; + const entries = view.entries.slice(); + const idx = entries.findIndex((e) => e.name === name); + if (idx < 0) return; + const grp = typeof entries[idx].def.group === 'string' ? entries[idx].def.group : null; + let j = idx + dir; + while (j >= 0 && j < entries.length) { + const g = typeof entries[j].def.group === 'string' ? entries[j].def.group : null; + if (g === grp) break; + j += dir; + } + if (j < 0 || j >= entries.length) return; + const tmp = entries[idx]; + entries[idx] = entries[j]; + entries[j] = tmp; + onPatch({ fields: writeFields({ shape: view.shape, entries }) }); + }, + [onPatch, view], + ); + // Drop a field into a group section's empty space (or onto its header). // Reassigns Field.group and moves the entry to the end of that group's // run in the source order so it visually lands where it was dropped. @@ -361,6 +386,7 @@ export function ObjectFormCanvas({ onClick={() => selectField(entry)} onReorder={readOnly ? undefined : reorderField} onRenameLabel={readOnly ? undefined : renameLabel} + onMoveOffset={readOnly ? undefined : (dir) => moveFieldByOffset(entry.name, dir)} /> ))} {g.entries.length === 0 && ( @@ -681,6 +707,7 @@ function FieldRow({ onClick, onReorder, onRenameLabel, + onMoveOffset, }: { entry: FieldEntry; selected: boolean; @@ -689,6 +716,8 @@ function FieldRow({ onClick: () => void; onReorder?: (fromName: string, toName: string, position: 'before' | 'after') => void; onRenameLabel?: (name: string, nextLabel: string) => void; + /** Keyboard reorder (Alt+↑/↓) — swap with same-group neighbour. */ + onMoveOffset?: (dir: -1 | 1) => void; }) { const def = entry.def; const typeStr = typeof def.type === 'string' ? (def.type as string) : 'text'; @@ -774,6 +803,13 @@ function FieldRow({ onClick={onClick} onKeyDown={(e) => { if (e.target !== e.currentTarget) return; + if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + if (onMoveOffset) { + e.preventDefault(); + onMoveOffset(e.key === 'ArrowUp' ? -1 : 1); + } + return; + } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick?.();