From 00253cd18f3ef3bc24c04d1eacd6d5f123310cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sat, 27 Jun 2026 16:11:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(grid):=20make=20inline=20cell=20editing?= =?UTF-8?q?=20usable=20=E2=80=94=20stop=20row-nav=20on=20edit,=20type-awar?= =?UTF-8?q?e=20editors,=20default=20persist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline editing was opt-in and MVP-level; turning it on for a 报工 grid surfaced three problems. This fixes all three. A) Bug: clicking an editable cell also opened the record-detail drawer. The cell onClick/onDoubleClick started edit mode but didn't stop propagation, so the row onClick still ran onRowClick. The row's "ignore interactive elements" heuristic missed it because the only renders on the next frame (the target is still a at click time). Now stopPropagation() is called when entering edit mode, so an editable cell only edits. Non-editable cells still navigate. B) Unfinished: the editor was a hardcoded text for every type. Added a small, readable, type-aware switch keyed on col.type: date → , datetime → (with local⇄ISO conversion), number/currency/percent → number input, else the original text input. ObjectGrid now forwards the resolved column type onto every data-table column build path so the editor can read it. select/boolean left as documented extension points. C) Missing default: edits didn't persist for declarative editable views. ObjectGrid passed onRowSave/onBatchSave straight through with no default, so a declarative editable:true view saved to nothing. Added dataSource-backed defaults (dataSource.update per row, keyed on _id ?? id, refresh after) installed only when the consumer omits the prop. They throw on failure so DataTable keeps pending changes instead of silently clearing them. --- .../src/renderers/complex/data-table.tsx | 134 ++++++++++++++++-- packages/plugin-grid/src/ObjectGrid.tsx | 69 ++++++++- 2 files changed, 192 insertions(+), 11 deletions(-) diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index a0cf306d2..e8c1fe958 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -61,6 +61,45 @@ import { type SortDirection = 'asc' | 'desc' | null; +/** + * Inline-edit helpers: convert a stored cell value to the string a native + * `` / `` expects, and back. + * + * Native date inputs require `yyyy-MM-dd`; datetime-local requires + * `yyyy-MM-ddTHH:mm`. We pad to the LOCAL wall-clock so the picker shows the + * same day the user sees, then convert back on change. A `date` field stays a + * plain `yyyy-MM-dd` string; a `datetime` field round-trips through an ISO + * string (matching how display/format code already treats ISO datetimes). + */ +function pad2(n: number): string { + return String(n).padStart(2, '0'); +} + +function toDateInputValue(value: unknown): string { + if (value == null || value === '') return ''; + // A bare yyyy-MM-dd (or its leading slice of an ISO string) is already in the + // exact shape the native control wants. Pass it through verbatim — parsing it + // through `new Date()` would interpret it as UTC midnight and can shift the + // displayed day by one in negative-offset timezones. + if (typeof value === 'string') { + const m = value.match(/^(\d{4}-\d{2}-\d{2})/); + if (m) return m[1]; + } + const d = value instanceof Date ? value : new Date(String(value)); + if (Number.isNaN(d.getTime())) return ''; + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +function toDateTimeInputValue(value: unknown): string { + if (value == null || value === '') return ''; + const d = value instanceof Date ? value : new Date(String(value)); + if (Number.isNaN(d.getTime())) return ''; + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`; +} + +// Field types that should edit as a numeric ``. +const NUMERIC_EDIT_TYPES = new Set(['number', 'currency', 'percent', 'int', 'integer', 'float', 'double']); + // Default English fallback translations for the data table const TABLE_DEFAULT_TRANSLATIONS: Record = { 'table.rowsPerPage': 'Rows per page', @@ -1062,19 +1101,96 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { maxWidth: columnWidth, ...(isFrozen && { left: frozenOffset }), }} - onDoubleClick={() => isEditable && !singleClickEdit && startEdit(rowIndex, col.accessorKey)} - onClick={() => isEditable && singleClickEdit && startEdit(rowIndex, col.accessorKey)} + onDoubleClick={(e) => { + // Entering edit mode must NOT also fire the row's + // onRowClick (record-detail drawer). The row heuristic + // can't see the editor yet (the only renders + // next frame), so stop propagation here explicitly. + if (isEditable && !singleClickEdit) { + e.stopPropagation(); + startEdit(rowIndex, col.accessorKey); + } + }} + onClick={(e) => { + if (isEditable && singleClickEdit) { + e.stopPropagation(); + startEdit(rowIndex, col.accessorKey); + } + }} onKeyDown={(e) => handleCellKeyDown(e, rowIndex, col.accessorKey)} tabIndex={0} > {isEditing ? ( - setEditValue(e.target.value)} - onKeyDown={handleEditKeyDown} - className="h-8 px-2 py-1" - /> + (() => { + // Type-aware inline editor. `col.type` is forwarded + // from ObjectGrid's column inference. Keep this a small, + // readable switch that's easy to extend. + const editType = (col as any).type as string | undefined; + + if (editType === 'date') { + return ( + setEditValue(e.target.value)} + onKeyDown={handleEditKeyDown} + className="h-8 px-2 py-1" + /> + ); + } + + if (editType === 'datetime' || editType === 'datetime-local') { + return ( + { + const v = e.target.value; + const d = v ? new Date(v) : null; + setEditValue(d && !Number.isNaN(d.getTime()) ? d.toISOString() : v); + }} + onKeyDown={handleEditKeyDown} + className="h-8 px-2 py-1" + /> + ); + } + + if (editType && NUMERIC_EDIT_TYPES.has(editType)) { + return ( + setEditValue(e.target.value)} + onKeyDown={handleEditKeyDown} + className="h-8 px-2 py-1" + /> + ); + } + + // NOTE: `select`/`boolean` editors are intentionally not + // wired here — the data-table column does not currently + // carry option metadata. Extension point: when `col.options` + // is forwarded from ObjectGrid, render a setEditValue(e.target.value)} + onKeyDown={handleEditKeyDown} + className="h-8 px-2 py-1" + /> + ); + })() ) : (
{typeof col.cell === 'function' diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 471f4305e..65c348947 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -849,6 +849,9 @@ export const ObjectGrid: React.FC = ({ return { ...col, + // Forward the resolved type so the inline editor (data-table) can + // pick a type-aware control (date picker, number, ...). + type: col.type ?? inferredType, ...(schema.showColumnTypeIcons && { headerIcon: getTypeIcon(inferredType) }), cell: (value: any) => , }; @@ -1010,6 +1013,10 @@ export const ObjectGrid: React.FC = ({ return { header, accessorKey: col.field, + // Forward the resolved (base) field type so the inline editor can + // pick a type-aware control. Use baseInferredType (date/number/...) + // rather than the renderer type so e.g. `date` stays `date`. + ...(baseInferredType && { type: baseInferredType }), ...(schema.showColumnTypeIcons && { headerIcon: getTypeIcon(inferredType) }), ...(!isEssential && { className: 'hidden sm:table-cell' }), ...(col.width && { width: col.width }), @@ -1090,6 +1097,8 @@ export const ObjectGrid: React.FC = ({ return { header, accessorKey: fieldName, + // Forward the resolved field type for the type-aware inline editor. + ...(resolvedType && { type: resolvedType }), ...(schema.showColumnTypeIcons && resolvedType && { headerIcon: getTypeIcon(resolvedType) }), ...(inferredAlign && { align: inferredAlign }), ...(cellRenderer && { cell: cellRenderer }), @@ -1133,6 +1142,8 @@ export const ObjectGrid: React.FC = ({ return { header, accessorKey: fieldName, + // Forward the resolved field type for the type-aware inline editor. + ...(resolvedType && { type: resolvedType }), ...(schema.showColumnTypeIcons && resolvedType && { headerIcon: getTypeIcon(resolvedType) }), ...(inferredAlign && { align: inferredAlign }), ...(CellRenderer && { cell: (value: any) => }), @@ -1197,6 +1208,8 @@ export const ObjectGrid: React.FC = ({ generatedColumns.push({ header: schema.objectName ? resolveFieldLabel(schema.objectName, fieldName, field.label || fieldName) : field.label || fieldName, accessorKey: fieldName, + // Forward the field type for the type-aware inline editor. + ...(field.type && { type: field.type }), ...(numericTypes.includes(field.type) && { align: 'right' }), cell: (value: any) => , sortable: field.sortable !== false, @@ -1544,6 +1557,56 @@ export const ObjectGrid: React.FC = ({ } }; + // Default inline-edit persistence. + // + // When a consumer wires `onRowSave`/`onBatchSave` (React host), we defer to it. + // But a declaratively-configured `editable: true` view has no host wiring — so + // "Save All" would otherwise just clear pending changes without writing to the + // backend. Supply a default that persists through the grid's `dataSource`, then + // refresh so the grid reflects persisted values. Throwing on failure is + // important: DataTable's saveRow/saveBatch keep pending changes when the save + // promise rejects, so a failed write doesn't silently lose the user's edits. + const resolveRecordId = (row: any): string | number | undefined => + row?._id ?? row?.id; + + const defaultRowSave = async ( + _rowIndex: number, + changes: Record, + row: any, + ): Promise => { + if (!dataSource || !objectName) { + throw new Error('Cannot persist inline edit: no dataSource/objectName configured on the grid.'); + } + const id = resolveRecordId(row); + if (id === undefined || id === null) { + throw new Error('Cannot persist inline edit: row has no id/_id.'); + } + await dataSource.update(objectName, id, changes); + // Refresh so the grid shows the persisted values. + setRefreshKey(k => k + 1); + }; + + const defaultBatchSave = async ( + changes: Array<{ rowIndex: number; changes: Record; row: any }>, + ): Promise => { + if (!dataSource || !objectName) { + throw new Error('Cannot persist inline edits: no dataSource/objectName configured on the grid.'); + } + // Update each modified row. The DataSource `bulk`/`bulkUpdate` primitives + // apply a single uniform patch across many ids, which does NOT fit per-row + // edits (each row has its own field changes), so issue one update per row. + await Promise.all( + changes.map(({ changes: rowChanges, row }) => { + const id = resolveRecordId(row); + if (id === undefined || id === null) { + throw new Error('Cannot persist inline edit: row has no id/_id.'); + } + return dataSource.update(objectName, id, rowChanges); + }), + ); + setRefreshKey(k => k + 1); + }; + // Determine pagination settings (support both new and legacy formats) const paginationEnabled = schema.pagination !== undefined ? true @@ -1639,8 +1702,10 @@ export const ObjectGrid: React.FC = ({ }, onRowClick: navigation.handleClick, onCellChange: onCellChange, - onRowSave: onRowSave, - onBatchSave: onBatchSave, + // Install a dataSource-backed default only when the consumer did NOT wire + // its own handler, so declarative `editable: true` views still persist. + onRowSave: onRowSave ?? defaultRowSave, + onBatchSave: onBatchSave ?? defaultBatchSave, onColumnResize: (columnKey: string, width: number) => { saveColumnState({ ...columnState, From 133fad634e2bac1aed95c41fc890eb18470bcac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sat, 27 Jun 2026 16:31:40 +0800 Subject: [PATCH 2/2] test(grid): regression test for inline-edit fix (no row-nav on edit cell, type-aware editors) --- .../__tests__/data-table-inline-edit.test.tsx | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 packages/components/src/__tests__/data-table-inline-edit.test.tsx diff --git a/packages/components/src/__tests__/data-table-inline-edit.test.tsx b/packages/components/src/__tests__/data-table-inline-edit.test.tsx new file mode 100644 index 000000000..ab5a16738 --- /dev/null +++ b/packages/components/src/__tests__/data-table-inline-edit.test.tsx @@ -0,0 +1,98 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Regression: inline cell editing on an `editable` data-table must be usable + * for per-row data entry (e.g. 生产报工 where each row gets its own actual + * date). Two bugs made it unusable: + * + * A) Clicking an editable cell entered edit mode AND bubbled up to the row's + * onClick → onRowClick, which in ObjectGrid opens the record-detail drawer. + * The edit cell must stopPropagation so the drawer never opens. + * B) The inline editor was a hardcoded text for every field type, so + * date columns could only be typed by hand. A `type: 'date'` column must + * render a native date picker (). + */ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { renderComponent } from './test-utils'; + +beforeAll(async () => { + await import('../renderers'); +}, 30000); + +const editableSchema = { + type: 'data-table' as const, + editable: true, + singleClickEdit: true, + columns: [ + { header: '工序', accessorKey: 'name', editable: false }, + { header: '实际开始时间', accessorKey: 'actual_start', type: 'date' }, + { header: '报工数量', accessorKey: 'qty', type: 'number' }, + ], + data: [{ id: '1', name: '将军柱下料', actual_start: '', qty: '' }], +} as any; + +describe('data-table — inline edit is per-row usable', () => { + it('A) clicking an editable cell does NOT fire onRowClick (no detail drawer)', () => { + const onRowClick = vi.fn(); + const { container } = renderComponent({ ...editableSchema, onRowClick }); + + const cell = Array.from(container.querySelectorAll('td')).find((td) => + td.textContent?.includes('将军柱下料') + ) as HTMLElement; + // sanity: the readonly name cell is editable:false, so it should still + // behave like a normal row click. + expect(cell).toBeTruthy(); + + // Click the editable date cell (3rd column has no text yet → locate by index). + const cells = container.querySelectorAll('tbody td'); + const dateCell = cells[1] as HTMLElement; // 实际开始时间 + fireEvent.click(dateCell); + + expect(onRowClick).not.toHaveBeenCalled(); + }); + + it('A2) clicking a readonly (editable:false) cell still fires onRowClick (sanity)', () => { + const onRowClick = vi.fn(); + const { container } = renderComponent({ ...editableSchema, onRowClick }); + + const nameCell = Array.from(container.querySelectorAll('tbody td')).find((td) => + td.textContent?.includes('将军柱下料') + ) as HTMLElement; + fireEvent.click(nameCell); + + expect(onRowClick).toHaveBeenCalledTimes(1); + }); + + it('B) a date column renders a native date picker, not a free-text input', () => { + const { container } = renderComponent(editableSchema); + + const cells = container.querySelectorAll('tbody td'); + const dateCell = cells[1] as HTMLElement; // 实际开始时间 + fireEvent.click(dateCell); + + const input = dateCell.querySelector('input') as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.type).toBe('date'); + }); + + it('B2) a number column renders a numeric input', () => { + const { container } = renderComponent(editableSchema); + + const cells = container.querySelectorAll('tbody td'); + const qtyCell = cells[2] as HTMLElement; // 报工数量 + fireEvent.click(qtyCell); + + const input = qtyCell.querySelector('input') as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.type).toBe('number'); + }); +});