Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions packages/components/src/__tests__/data-table-inline-edit.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <Input> for every field type, so
* date columns could only be typed by hand. A `type: 'date'` column must
* render a native date picker (<input type="date">).
*/
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');
});
});
134 changes: 125 additions & 9 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,45 @@ import {

type SortDirection = 'asc' | 'desc' | null;

/**
* Inline-edit helpers: convert a stored cell value to the string a native
* `<input type="date">` / `<input type="datetime-local">` 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 `<Input type="number">`.
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<string, string> = {
'table.rowsPerPage': 'Rows per page',
Expand Down Expand Up @@ -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 <input> 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 ? (
<Input
ref={editInputRef}
value={editValue}
onChange={(e) => 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 (
<Input
ref={editInputRef}
type="date"
value={toDateInputValue(editValue)}
// Store a plain yyyy-MM-dd string — matches how
// date fields are displayed/persisted elsewhere.
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown}
className="h-8 px-2 py-1"
/>
);
}

if (editType === 'datetime' || editType === 'datetime-local') {
return (
<Input
ref={editInputRef}
type="datetime-local"
value={toDateTimeInputValue(editValue)}
// The native control yields a local `yyyy-MM-ddTHH:mm`;
// store back as an ISO string so display/format code
// (formatCellValue) renders it consistently.
onChange={(e) => {
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 (
<Input
ref={editInputRef}
type="number"
value={editValue ?? ''}
onChange={(e) => 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 <Select>/checkbox.

// Fallback: plain text input (original behavior).
return (
<Input
ref={editInputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown}
className="h-8 px-2 py-1"
/>
);
})()
) : (
<div className="truncate w-full" title={cellValue != null && typeof cellValue !== 'object' ? String(cellValue) : undefined}>
{typeof col.cell === 'function'
Expand Down
69 changes: 67 additions & 2 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,9 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({

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) => <CellRenderer value={value} field={fieldMeta as any} />,
};
Expand Down Expand Up @@ -1010,6 +1013,10 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
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 }),
Expand Down Expand Up @@ -1090,6 +1097,8 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
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 }),
Expand Down Expand Up @@ -1133,6 +1142,8 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
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) => <CellRenderer value={value} field={fieldMeta as any} /> }),
Expand Down Expand Up @@ -1197,6 +1208,8 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
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) => <CellRenderer value={value} field={fieldForCell} />,
sortable: field.sortable !== false,
Expand Down Expand Up @@ -1544,6 +1557,56 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
}
};

// 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<string, any>,
row: any,
): Promise<void> => {
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<string, any>; row: any }>,
): Promise<void> => {
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
Expand Down Expand Up @@ -1639,8 +1702,10 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
},
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,
Expand Down
Loading