From b13c888a0051bb09883570987f95743d8b3f9fbf Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:32:40 +0800 Subject: [PATCH] feat(studio): bulk multi-select of fields in the object designer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select multiple fields on the canvas and act on them at once — the third deferred Tier-2 item. - Ctrl/⌘-click toggles a field into the selection; Shift-click selects a contiguous range; a plain click clears the set and single-selects. - A sticky bulk-action bar appears while ≥1 field is selected: "N selected · Move to section ▾ · Delete · Clear". Move-to-section is only offered when sections exist. - Selected rows show a check indicator + ring. Selection is canvas-local (no host coupling) and self-prunes when fields disappear; deleting the host-selected field clears the inspector. Read-only canvases never enter multi-select. All strings localized (en-US / zh-CN). Tests: +7 RTL cases (ctrl-click count, bulk delete, shift-range, plain click clears + single-selects, read-only inert, move-to-section gating + group assignment). metadata-admin suite green (109); tsc + lint clean. Co-Authored-By: Claude Opus 4.8 --- .../src/views/metadata-admin/i18n.ts | 12 ++ .../previews/ObjectFormCanvas.test.tsx | 69 +++++++ .../previews/ObjectFormCanvas.tsx | 175 +++++++++++++++++- 3 files changed, 252 insertions(+), 4 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index ea951a6ea..20f1efb22 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -583,6 +583,12 @@ const ENGINE_STRINGS_EN: Record = { 'designer.field.minLength': 'Min length', 'designer.field.conditionalRequired': 'Required when (CEL)', 'designer.field.conditionalRequiredHint': 'Field becomes required when this predicate is true.', + // Bulk multi-select (Tier 2) + 'designer.canvas.bulkSelected': '{n} selected', + 'designer.canvas.bulkMoveTo': 'Move to section', + 'designer.canvas.bulkDelete': 'Delete', + 'designer.canvas.bulkClear': 'Clear', + 'designer.canvas.bulkHint': 'Ctrl/⌘-click or Shift-click to select multiple fields.', }; const ENGINE_STRINGS_ZH: Record = { @@ -1025,6 +1031,12 @@ const ENGINE_STRINGS_ZH: Record = { 'designer.field.minLength': '最小长度', 'designer.field.conditionalRequired': '条件必填 (CEL)', 'designer.field.conditionalRequiredHint': '当此表达式为真时,该字段变为必填。', + // Bulk multi-select (Tier 2) + 'designer.canvas.bulkSelected': '已选 {n} 项', + 'designer.canvas.bulkMoveTo': '移至分组', + 'designer.canvas.bulkDelete': '删除', + 'designer.canvas.bulkClear': '取消选择', + 'designer.canvas.bulkHint': '按住 Ctrl/⌘ 单击或 Shift 单击可多选字段。', }; function pickTable( 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 0bc7bdc69..260e92d50 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 @@ -184,3 +184,72 @@ describe('ObjectFormCanvas — keyboard reorder (Alt+↑/↓)', () => { expect(screen.getByText('Alpha')).toBeInTheDocument(); }); }); + +describe('ObjectFormCanvas — bulk multi-select', () => { + const flat = { + name: 'x', + fields: { + a: { type: 'text', label: 'A' }, + b: { type: 'text', label: 'B' }, + c: { type: 'text', label: 'C' }, + }, + }; + const rowFor = (label: string) => screen.getByText(label).closest('[role="button"]')!; + + it('Ctrl-click enters multi-select and shows the bulk bar', () => { + render(); + fireEvent.click(rowFor('A'), { ctrlKey: true }); + expect(screen.getByText('1 selected')).toBeInTheDocument(); + fireEvent.click(rowFor('B'), { ctrlKey: true }); + expect(screen.getByText('2 selected')).toBeInTheDocument(); + }); + + it('bulk delete removes every selected field', () => { + const onPatch = vi.fn(); + render(); + fireEvent.click(rowFor('A'), { ctrlKey: true }); + fireEvent.click(rowFor('B'), { ctrlKey: true }); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + expect(Object.keys(onPatch.mock.calls.at(-1)![0].fields)).toEqual(['c']); + }); + + it('Shift-click selects a contiguous range', () => { + render(); + fireEvent.click(rowFor('A')); // plain click → anchor + fireEvent.click(rowFor('C'), { shiftKey: true }); + expect(screen.getByText('3 selected')).toBeInTheDocument(); + }); + + it('a plain click clears the multi-selection and single-selects', () => { + const onSelectionChange = vi.fn(); + render(); + fireEvent.click(rowFor('A'), { ctrlKey: true }); + expect(screen.getByText('1 selected')).toBeInTheDocument(); + fireEvent.click(rowFor('B')); // plain + expect(screen.queryByText('1 selected')).not.toBeInTheDocument(); + expect(onSelectionChange).toHaveBeenLastCalledWith(expect.objectContaining({ id: 'b' })); + }); + + it('is inert in read-only mode (no onPatch)', () => { + render(); + fireEvent.click(rowFor('A'), { ctrlKey: true }); + expect(screen.queryByText(/selected/)).not.toBeInTheDocument(); + }); + + it('only offers "Move to section" when sections exist', () => { + // No declared groups → no move target. + render(); + fireEvent.click(rowFor('A'), { ctrlKey: true }); + expect(screen.queryByRole('button', { name: /Move to section/ })).not.toBeInTheDocument(); + }); + + it('bulk move-to-section assigns the group to selected fields', () => { + const onPatch = vi.fn(); + render(); + fireEvent.click(rowFor('Notes'), { ctrlKey: true }); // an ungrouped field + fireEvent.click(screen.getByRole('button', { name: /Move to section/ })); + // Popover lists Ungrouped + the declared sections; pick "Metadata" (key 'meta'). + fireEvent.click(screen.getByRole('button', { name: 'Metadata' })); + expect(onPatch.mock.calls.at(-1)![0].fields.notes.group).toBe('meta'); + }); +}); 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 f46413fef..39022a46a 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.tsx @@ -34,8 +34,11 @@ import { ArrowUp, ArrowDown, FolderPlus, + FolderInput, ChevronsDownUp, ChevronsUpDown, + CheckSquare, + X, } from 'lucide-react'; import type { MetadataSelection } from '../preview-registry'; import { @@ -139,6 +142,77 @@ export function ObjectFormCanvas({ [onSelectionChange], ); + /* ─── Multi-select (bulk ops) — canvas-local; no host coupling ─── */ + + const [multiSel, setMultiSel] = React.useState>(() => new Set()); + const anchorRef = React.useRef(null); + // Flat rendered order, for Shift-range selection. + const flatNames = React.useMemo( + () => groups.flatMap((g) => g.entries.map((e) => e.name)), + [groups], + ); + // Drop names that no longer exist (e.g. after a bulk delete elsewhere). + React.useEffect(() => { + setMultiSel((prev) => { + if (prev.size === 0) return prev; + const live = new Set([...prev].filter((n) => view.entries.some((e) => e.name === n))); + return live.size === prev.size ? prev : live; + }); + }, [view]); + + const handleRowClick = React.useCallback( + (entry: FieldEntry, e?: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean }) => { + const name = entry.name; + if (!readOnly && e && (e.metaKey || e.ctrlKey)) { + setMultiSel((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + anchorRef.current = name; + return; + } + if (!readOnly && e && e.shiftKey && anchorRef.current && anchorRef.current !== name) { + const a = flatNames.indexOf(anchorRef.current); + const b = flatNames.indexOf(name); + if (a >= 0 && b >= 0) { + const [lo, hi] = a < b ? [a, b] : [b, a]; + setMultiSel(new Set(flatNames.slice(lo, hi + 1))); + return; + } + } + // Plain click — clear multi-selection, single-select. + if (multiSel.size) setMultiSel(new Set()); + anchorRef.current = name; + selectField(entry); + }, + [readOnly, flatNames, multiSel, selectField], + ); + + const clearMulti = React.useCallback(() => setMultiSel(new Set()), []); + + const bulkDelete = React.useCallback(() => { + if (!onPatch || multiSel.size === 0) return; + const entries = view.entries.filter((e) => !multiSel.has(e.name)); + onPatch({ fields: writeFields({ shape: view.shape, entries }) }); + if (selectedName && multiSel.has(selectedName)) onSelectionChange?.(null); + setMultiSel(new Set()); + }, [onPatch, multiSel, view, selectedName, onSelectionChange]); + + const bulkSetGroup = React.useCallback( + (groupKey: string | null) => { + if (!onPatch || multiSel.size === 0) return; + const entries = view.entries.map((e) => + multiSel.has(e.name) + ? { name: e.name, def: { ...e.def, group: groupKey ?? undefined } } + : e, + ); + onPatch({ fields: writeFields({ shape: view.shape, entries }) }); + }, + [onPatch, multiSel, view], + ); + const addField = React.useCallback( (type: FieldTypeId, groupKey?: string | null) => { if (!onPatch) return; @@ -337,6 +411,16 @@ export function ObjectFormCanvas({ onClick={handleBgClick} data-object-name={objectName} > + {!readOnly && multiSel.size > 0 && ( + + )}
{!emptyState && ( selectField(entry)} + onClick={(e) => handleRowClick(entry, e)} onReorder={readOnly ? undefined : reorderField} onRenameLabel={readOnly ? undefined : renameLabel} onMoveOffset={readOnly ? undefined : (dir) => moveFieldByOffset(entry.name, dir)} @@ -474,6 +559,81 @@ function CanvasToolbar({ ); } +/* ─────────────── Bulk-action bar ─────────────── */ + +function BulkActionBar({ + count, + groups, + onMoveToGroup, + onDelete, + onClear, + locale, +}: { + count: number; + groups: FieldGroup[]; + onMoveToGroup: (groupKey: string | null) => void; + onDelete: () => void; + onClear: () => void; + locale?: string; +}) { + const [open, setOpen] = React.useState(false); + return ( +
+ + {tFormat('designer.canvas.bulkSelected', locale, { n: count })} + + + {t('designer.canvas.bulkHint', locale)} + +
+ {groups.length > 0 && ( + + + + + + + {groups.map((g) => ( + + ))} + + + )} + + +
+
+ ); +} + /* ─────────────── Building blocks ─────────────── */ function GroupSection({ @@ -702,6 +862,7 @@ function SectionHeader({ function FieldRow({ entry, selected, + multiSelected, readOnly, locale, onClick, @@ -711,9 +872,11 @@ function FieldRow({ }: { entry: FieldEntry; selected: boolean; + multiSelected?: boolean; readOnly: boolean; locale?: string; - onClick: () => void; + /** Receives the mouse event so the host can branch on Ctrl/⌘/Shift. */ + onClick: (e?: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean }) => void; onReorder?: (fromName: string, toName: string, position: 'before' | 'after') => void; onRenameLabel?: (name: string, nextLabel: string) => void; /** Keyboard reorder (Alt+↑/↓) — swap with same-group neighbour. */ @@ -821,14 +984,18 @@ function FieldRow({ 'group block w-full text-left rounded-md border bg-card px-3.5 py-2.5 transition-colors', 'hover:border-primary/40 hover:bg-card outline-none focus-visible:ring-2 focus-visible:ring-primary/40', selected ? 'border-primary ring-2 ring-primary/30 shadow-sm' : 'border-border', + multiSelected && 'border-primary/60 ring-2 ring-primary/40 bg-primary/[0.04]', readOnly && 'cursor-default', draggable && 'cursor-grab active:cursor-grabbing', )} - aria-pressed={selected} + aria-pressed={selected || multiSelected} >
- {draggable && ( + {multiSelected && ( +