diff --git a/.changeset/line-grid-p1-affordances.md b/.changeset/line-grid-p1-affordances.md new file mode 100644 index 000000000..bae9f3cc1 --- /dev/null +++ b/.changeset/line-grid-p1-affordances.md @@ -0,0 +1,9 @@ +--- +"@object-ui/fields": minor +--- + +Line-item grid: inline validation, duplicate, and drag-to-reorder. + +- **Inline per-cell validation** — a required, non-computed cell that's empty on a real (non-ghost) row flags red in place (`aria-invalid` + ring), so errors are visible without submitting. +- **Duplicate row** — a hover Copy action clones a line (id stripped) directly below it, for near-identical lines. +- **Drag-to-reorder** — a hover grip handle reorders rows via native drag-and-drop. Set `sort_field` on the grid config to persist order (`row[sortField] = index` stamped on every change); otherwise reorder is order-of-entry. diff --git a/packages/fields/src/widgets/GridField.test.tsx b/packages/fields/src/widgets/GridField.test.tsx index 57c4ae4ec..855b4c4ec 100644 --- a/packages/fields/src/widgets/GridField.test.tsx +++ b/packages/fields/src/widgets/GridField.test.tsx @@ -223,6 +223,27 @@ describe('GridField / LineItemsField — editable line items', () => { }); }); + describe('P1 affordances (duplicate / validation)', () => { + it('duplicates a row (id stripped) directly below the original', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('line-items-duplicate-0')); + expect(onChange).toHaveBeenCalledWith([ + { id: 'r1', description: 'A', amount: 5 }, + { description: 'A', amount: 5 }, // copy without the id → persists as a new record + ]); + }); + + it('flags a required, empty cell on a real row (not the ghost row)', () => { + const reqField = { columns: [{ field: 'description', label: 'Description', type: 'text' as const, required: true }] } as any; + render( {}} field={reqField} />); + // The data row's required-empty cell is flagged... + expect(screen.getByTestId('line-items-invalid-0-description')).toBeTruthy(); + // ...but the trailing ghost row (index 1) is not. + expect(screen.queryByTestId('line-items-invalid-1-description')).toBeNull(); + }); + }); + it('sumColumn ignores blanks and NaN', () => { expect(sumColumn([{ amount: 1 }, { amount: 2 }, { amount: null }], 'amount')).toBe(3); }); diff --git a/packages/fields/src/widgets/GridField.tsx b/packages/fields/src/widgets/GridField.tsx index 89edf222f..af0d5c157 100644 --- a/packages/fields/src/widgets/GridField.tsx +++ b/packages/fields/src/widgets/GridField.tsx @@ -15,7 +15,7 @@ import { Checkbox, Label, } from '@object-ui/components'; -import { Plus, Trash2, SlidersHorizontal, Maximize2 } from 'lucide-react'; +import { Plus, Trash2, SlidersHorizontal, Maximize2, Copy, GripVertical } from 'lucide-react'; import { LookupField } from './LookupField'; /** @@ -317,19 +317,27 @@ export function GridField({ const allowAdd = cfg.allow_add !== false && !readonly && !disabled; const allowDelete = cfg.allow_delete !== false && !readonly && !disabled; + const allowDuplicate = cfg.allow_duplicate !== false && allowAdd; // Per-row "expand to full form" (mainstream hybrid: quick grid + rich form). const showExpand = typeof onRowExpand === 'function' && !readonly; - const hasRowActions = showExpand || allowDelete; // Enterprise line grids (NetSuite/SAP/Salesforce) show a line-number column. const showLineNumbers = cfg.show_line_numbers !== false; const minRows: number = cfg.min_rows ?? 0; const maxRows: number | undefined = cfg.max_rows; const totalField: string | undefined = cfg.total_field || cfg.amount_field || cfg.amountField; + // When set, the row's order is persisted by stamping `row[sortField] = index` + // on every change — so drag-reorder survives a reload (the app adds a numeric + // position field and lists sort by it). Without it, reorder is order-of-entry. + const sortField: string | undefined = cfg.sort_field; + // Drag-to-reorder is on for editable grids (off in read-only / list mode). + const allowReorder = cfg.reorderable !== false && !readonly && !disabled; const emit = useCallback( - (next: Row[]) => onChange?.(next), - [onChange], + (next: Row[]) => { + onChange?.(sortField ? next.map((r, i) => ({ ...r, [sortField]: i })) : next); + }, + [onChange, sortField], ); const blankRow = useCallback((): Row => { @@ -428,6 +436,37 @@ export function GridField({ [rows, minRows, emit], ); + /** Duplicate a row (id stripped so the copy persists as a new record), + * inserted directly below the original — handy for near-identical lines. */ + const duplicateRow = useCallback( + (rowIdx: number) => { + if (maxRows != null && rows.length >= maxRows) return; + const src = rows[rowIdx]; + if (!src) return; + const { id: _i, _id: _i2, recordId: _i3, ...copy } = src as any; + const next = [...rows]; + next.splice(rowIdx + 1, 0, { ...copy }); + emit(next); + }, + [rows, maxRows, emit], + ); + + /** Move a row from one position to another (drag-to-reorder). */ + const moveRow = useCallback( + (from: number, to: number) => { + if (from === to || from < 0 || to < 0 || from >= rows.length || to >= rows.length) return; + const next = [...rows]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + emit(next); + }, + [rows, emit], + ); + const dragIndex = useRef(null); + + const hasRowActions = showExpand || allowDelete || allowDuplicate; + const actionColWidth = ((showExpand ? 1 : 0) + (allowDuplicate ? 1 : 0) + (allowDelete ? 1 : 0)) * 34 + 12; + const showTotal = !!totalField; const total = showTotal ? sumColumn(rows, totalField!) : 0; // Align the running total under the column it sums (not blindly under the @@ -664,7 +703,7 @@ export function GridField({ {showLineNumbers && ( - # + # )} {columns.map((c) => ( *} ))} - {hasRowActions && } + {hasRowActions && } @@ -695,54 +734,114 @@ export function GridField({ ) : ( displayRows.map((row, rowIdx) => { const isGhost = hasGhost && rowIdx === rows.length; + const reorderable = allowReorder && !isGhost && !isList; return ( - + { if (dragIndex.current != null) e.preventDefault(); }, + onDrop: (e: React.DragEvent) => { + e.preventDefault(); + if (dragIndex.current != null) { moveRow(dragIndex.current, rowIdx); dragIndex.current = null; } + }, + } + : {})} + > {showLineNumbers && ( - - {rowIdx + 1} + + + {reorderable && ( + { dragIndex.current = rowIdx; }} + onDragEnd={() => { dragIndex.current = null; }} + className="cursor-grab text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" + title="Drag to reorder" + aria-label="Drag to reorder" + data-testid={`line-items-drag-${rowIdx}`} + > + + + )} + {rowIdx + 1} + )} - {columns.map((c, colIdx) => ( - - {renderCellInput(c, colIdx, rowIdx, row)} - - ))} + {columns.map((c, colIdx) => { + // Inline validation: a required, non-computed cell that's + // empty on a real (non-ghost) row flags red in place. + const invalid = !isGhost && !isList && !!c.required && !c.computed && (row[c.field] == null || row[c.field] === ''); + return ( + + {renderCellInput(c, colIdx, rowIdx, row)} + + ); + })} {hasRowActions && ( - - {!isGhost && showExpand && ( - - )} - {!isGhost && allowDelete && ( - - )} + +
+ {!isGhost && showExpand && ( + + )} + {!isGhost && allowDuplicate && ( + + )} + {!isGhost && allowDelete && ( + + )} +
)}