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'); + }); +}); 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,