diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7b1e562..8e4e2272 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,9 @@ jobs: name: Lint, typecheck, test, build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 20 cache: npm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 891da166..7a3380ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,9 +17,9 @@ jobs: name: Lint, typecheck, test, build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 24 @@ -45,12 +45,12 @@ jobs: contents: write id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 24 diff --git a/CHANGELOG.md b/CHANGELOG.md index eccbdfef..db2ba88f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ A summary of notable changes per release. For the full commit history see the [repository on GitHub](https://github.com/jbetancur/react-data-table-component/commits/master). +## 8.7.0 + +### New features + +- **`ctx.error` for custom editors** — the custom editor render context now includes the current validation error (`string | null`), so custom editors can style their own invalid state when `validate` rejects a commit. → [Inline editing](/docs/inline-editing) ([#1355](https://github.com/jbetancur/react-data-table-component/issues/1355)) +- **`ctx.inputRef` for custom editors** — attach it as the `ref` of your focusable element to get auto-focus when the editor opens and refocus after a rejected commit, matching the built-in editors. → [Inline editing](/docs/inline-editing) ([#1355](https://github.com/jbetancur/react-data-table-component/issues/1355)) + +--- + ## 8.6.2 ### Bug fixes diff --git a/apps/docs/public/llms.txt b/apps/docs/public/llms.txt index b332f946..fb2c5d07 100644 --- a/apps/docs/public/llms.txt +++ b/apps/docs/public/llms.txt @@ -49,7 +49,7 @@ const columns = [ - [Fixed Header](https://reactdatatable.com/docs/fixed-header): sticky header with scrollable body; when to disable the responsive scroll container - [Footer](https://reactdatatable.com/docs/footer): summary and pagination footers - [Loading State](https://reactdatatable.com/docs/loading): progress and skeleton states -- [Inline Editing](https://reactdatatable.com/docs/inline-editing): edit cells in place +- [Inline Editing](https://reactdatatable.com/docs/inline-editing): edit cells in place with built-in editor types, per-column validation, and optimistic server-save patterns ## Styling diff --git a/apps/docs/src/components/demos/InlineEditingDemo.tsx b/apps/docs/src/components/demos/InlineEditingDemo.tsx index 2ad8b08d..bcc89f39 100644 --- a/apps/docs/src/components/demos/InlineEditingDemo.tsx +++ b/apps/docs/src/components/demos/InlineEditingDemo.tsx @@ -34,7 +34,7 @@ export default function InlineEditingDemo() { const handleCellEdit = (row: Employee, value: string, column: TableColumn) => { const field = column.id as keyof Employee; - const parsed = field === 'salary' ? Number(value) || row.salary : field === 'remote' ? value === 'true' : value; + const parsed = field === 'salary' ? Number(value) : field === 'remote' ? value === 'true' : value; setData(prev => prev.map(r => (r.id === row.id ? { ...r, [field]: parsed } : r))); setLastEdit(`Updated ${row.name} → ${String(column.name)}: "${value}"`); }; @@ -46,6 +46,7 @@ export default function InlineEditingDemo() { selector: r => r.name, sortable: true, editable: true, + validate: value => (value.trim() === '' ? 'Name is required' : true), onCellEdit: handleCellEdit, }, { @@ -99,6 +100,10 @@ export default function InlineEditingDemo() { sortable: true, right: true, editable: true, + validate: value => { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? true : 'Enter a positive number'; + }, onCellEdit: handleCellEdit, }, { @@ -117,8 +122,10 @@ export default function InlineEditingDemo() {

Click any cell to edit. Name and Salary are text inputs;{' '} Department and Status are dropdowns; Remote is a checkbox.{' '} - Enter commits, Esc cancels. Keyboard navigation is enabled too: click or Tab into the - table, move between cells and headers with the arrow keys, and press Enter to edit or sort. + Enter commits, Esc cancels. Name and Salary are + validated: try committing an empty name or a negative salary to see the inline error. Keyboard navigation is + enabled too: click or Tab into the table, move between cells and headers with the arrow keys, and press{' '} + Enter to edit or sort.

{lastEdit &&
{lastEdit}
} diff --git a/apps/docs/src/components/demos/ServerSideEditDemo.tsx b/apps/docs/src/components/demos/ServerSideEditDemo.tsx new file mode 100644 index 00000000..a51a3f83 --- /dev/null +++ b/apps/docs/src/components/demos/ServerSideEditDemo.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import DataTable from '../ThemedDataTable'; +import { type TableColumn } from 'react-data-table-component'; + +interface Product { + id: number; + sku: string; + name: string; + stock: number; + price: number; +} + +const initialData: Product[] = [ + { id: 1, sku: 'CH-114', name: 'Aeron Chair', stock: 12, price: 745 }, + { id: 2, sku: 'DK-208', name: 'Standing Desk', stock: 7, price: 890 }, + { id: 3, sku: 'MN-330', name: '32" Monitor', stock: 24, price: 429 }, + { id: 4, sku: 'KB-501', name: 'Mech Keyboard', stock: 41, price: 159 }, + { id: 5, sku: 'LM-612', name: 'Desk Lamp', stock: 63, price: 49 }, +]; + +function saveToServer(field: keyof Product, value: string | number): Promise { + return new Promise((resolve, reject) => + setTimeout(() => { + if (field === 'price' && Number(value) > 1000) reject(new Error('Price exceeds the $1,000 cap')); + else resolve(); + }, 900), + ); +} + +export default function ServerSideEditDemo() { + const [data, setData] = React.useState(initialData); + const [savingId, setSavingId] = React.useState(null); + const [status, setStatus] = React.useState<{ ok: boolean; message: string } | null>(null); + + const handleCellEdit = async (row: Product, value: string, column: TableColumn) => { + const field = column.id as keyof Product; + const parsed = field === 'name' ? value : Number(value); + setData(prev => prev.map(r => (r.id === row.id ? { ...r, [field]: parsed } : r))); + setSavingId(row.id); + setStatus({ ok: true, message: `Saving ${row.sku}…` }); + try { + await saveToServer(field, parsed); + setStatus({ ok: true, message: `Saved ${row.sku} → ${String(column.name)}: "${value}"` }); + } catch (err) { + setData(prev => prev.map(r => (r.id === row.id ? { ...r, [field]: row[field] } : r))); + setStatus({ ok: false, message: `${(err as Error).message} — rolled back ${row.sku}` }); + } finally { + setSavingId(null); + } + }; + + const columns: TableColumn[] = [ + { id: 'sku', name: 'SKU', selector: r => r.sku, width: '90px' }, + { id: 'name', name: 'Product', selector: r => r.name, editable: true, onCellEdit: handleCellEdit }, + { + id: 'stock', + name: 'Stock', + selector: r => r.stock, + right: true, + editor: { type: 'number', min: 0, step: 1 }, + validate: value => (Number.isInteger(Number(value)) && Number(value) >= 0 ? true : 'Enter a whole number'), + onCellEdit: handleCellEdit, + }, + { + id: 'price', + name: 'Price', + selector: r => r.price, + format: r => `$${r.price.toLocaleString()}`, + right: true, + editor: { type: 'number', min: 0, step: 1 }, + validate: value => (Number(value) > 0 ? true : 'Enter a positive number'), + onCellEdit: handleCellEdit, + }, + ]; + + return ( +
+

+ Edits apply optimistically, then save to a simulated server with ~1s latency. The row dims while the save is in + flight. The server rejects any Price over $1,000 — try it to watch the edit roll back. +

+ r.id === savingId, style: { opacity: 0.45 } }]} + /> + {status && ( +
{status.message}
+ )} +
+ ); +} diff --git a/apps/docs/src/pages/docs/api.astro b/apps/docs/src/pages/docs/api.astro index e501f389..10fcc8a3 100644 --- a/apps/docs/src/pages/docs/api.astro +++ b/apps/docs/src/pages/docs/api.astro @@ -195,6 +195,8 @@ interface CustomCellEditorContext { commit: (value?: string) => void; cancel: () => void; column: TableColumn; + error: string | null; + inputRef: React.RefCallback; }`} lang="ts" /> ) => { const field = column.id as keyof Employee; setData(prev => - prev.map(r => (r.id === row.id ? { ...r, [field]: field === 'salary' ? Number(value) || r.salary : value } : r)), + prev.map(r => (r.id === row.id ? { ...r, [field]: field === 'salary' ? Number(value) : value } : r)), ); }; const columns: TableColumn[] = [ // Text editor — editable: true is shorthand for { editor: { type: 'text' } } - { id: 'name', name: 'Name', selector: r => r.name, editable: true, onCellEdit: handleCellEdit }, + { + id: 'name', + name: 'Name', + selector: r => r.name, + editable: true, + validate: value => (value.trim() === '' ? 'Name is required' : true), + onCellEdit: handleCellEdit, + }, // Dropdown editor { @@ -96,6 +104,10 @@ export default function App() { format: r => \`$\${r.salary.toLocaleString()}\`, right: true, editable: true, + validate: value => { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? true : 'Enter a positive number'; + }, onCellEdit: handleCellEdit, }, ]; @@ -216,9 +228,9 @@ export default function App() { id: 'role', name: 'Role', selector: r => r.role, editor: { type: 'custom', - render: ({ value, setValue, commit, cancel, row }) => ( + render: ({ value, setValue, commit, cancel, row, inputRef }) => ( commit(v)} @@ -236,6 +248,17 @@ export default function App() { your custom editor only needs to render the input.

+

+ Validation works the same as for built-in editors: ctx.commit runs the + column's validate first, and a string result keeps the editor open with the + inline error tooltip. Since 8.7.0 the render context also carries the current error as + ctx.error (string | null), so your editor can style its own + invalid state, e.g. aria-invalid={!!ctx.error}. Attach + ctx.inputRef as the ref of your focusable element to get the + same focus handling as the built-in editors: auto-focus when the editor opens and + refocus after a rejected commit. +

+

The cell becomes the editor

@@ -293,6 +316,60 @@ export default function App() { } };`} /> + (initialData); + const [savingId, setSavingId] = useState(null); + + const handleCellEdit = async (row: Product, value: string, column: TableColumn) => { + const field = column.id as keyof Product; + const parsed = field === 'name' ? value : Number(value); + + // Optimistic update + setData(prev => prev.map(r => (r.id === row.id ? { ...r, [field]: parsed } : r))); + setSavingId(row.id); + + try { + await api.updateProduct(row.id, { [field]: parsed }); + } catch (err) { + // Roll back the field to its pre-edit value + setData(prev => prev.map(r => (r.id === row.id ? { ...r, [field]: row[field] } : r))); + } finally { + setSavingId(null); + } + }; + + const columns: TableColumn[] = [ + { id: 'sku', name: 'SKU', selector: r => r.sku }, + { id: 'name', name: 'Product', selector: r => r.name, editable: true, onCellEdit: handleCellEdit }, + { + id: 'price', + name: 'Price', + selector: r => r.price, + right: true, + editor: { type: 'number', min: 0 }, + validate: value => (Number(value) > 0 ? true : 'Enter a positive number'), + onCellEdit: handleCellEdit, + }, + ]; + + return ( + r.id === savingId, style: { opacity: 0.45 } }]} + /> + ); +}`} + > + + +

Validation

Add a validate function to any column to gate the edit before diff --git a/package.json b/package.json index c364f013..edc9518c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-data-table-component", - "version": "8.6.2", + "version": "8.7.0", "description": "A fast, feature-rich React data table. Working table in 10 lines.", "funding": [ { diff --git a/src/__tests__/inlineEditing.test.tsx b/src/__tests__/inlineEditing.test.tsx index 2e079008..16e19d10 100644 --- a/src/__tests__/inlineEditing.test.tsx +++ b/src/__tests__/inlineEditing.test.tsx @@ -224,4 +224,78 @@ describe('inline editing: custom editor', () => { await waitFor(() => expect(onCellEdit).toHaveBeenCalled()); expect(onCellEdit.mock.calls[0][1]).toBe('Zara'); }); + + test('receives the validation error via ctx.error after a rejected commit', async () => { + const onCellEdit = vi.fn(); + const columns: TableColumn[] = [ + { + id: 'name', + name: 'Name', + selector: r => r.name, + editor: { + type: 'custom', + render: ctx => ( + + ), + }, + validate: value => (value === '' ? 'Must not be empty' : true), + onCellEdit, + }, + ]; + + const { getByText } = render(); + clickCell(getByText, 'Alice'); + + const btn = await screen.findByTestId('custom-commit'); + expect(btn).toHaveAttribute('data-error', ''); + fireEvent.click(btn); + + await waitFor(() => expect(screen.getByTestId('custom-commit')).toHaveAttribute('data-error', 'Must not be empty')); + expect(onCellEdit).not.toHaveBeenCalled(); + }); + + test('focuses the element attached to ctx.inputRef on open and after a rejected commit', async () => { + const onCellEdit = vi.fn(); + const columns: TableColumn[] = [ + { + id: 'name', + name: 'Name', + selector: r => r.name, + editor: { + type: 'custom', + render: ctx => ( + <> + ctx.setValue(e.target.value)} + /> + + + ), + }, + validate: value => (value === '' ? 'Must not be empty' : true), + onCellEdit, + }, + ]; + + const { getByText } = render(); + clickCell(getByText, 'Alice'); + + const input = await screen.findByTestId('custom-input'); + await waitFor(() => expect(input).toHaveFocus()); + + const saveBtn = screen.getByTestId('custom-save'); + saveBtn.focus(); + expect(input).not.toHaveFocus(); + fireEvent.click(saveBtn); + + await waitFor(() => expect(input).toHaveFocus()); + expect(onCellEdit).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/CellEditor.tsx b/src/components/CellEditor.tsx index f83a431a..5b76fd9e 100644 --- a/src/components/CellEditor.tsx +++ b/src/components/CellEditor.tsx @@ -17,6 +17,7 @@ function CellEditor({ edit, row, column, cellNavigation }: CellEditorProps setEditValue, editError, inputRef, + customInputRef, seedValue, cancelEdit, commitEdit, @@ -102,6 +103,8 @@ function CellEditor({ edit, row, column, cellNavigation }: CellEditorProps commit: commitEdit, cancel: cancelEdit, column, + error: editError, + inputRef: customInputRef, })} )} diff --git a/src/hooks/useCellEdit.ts b/src/hooks/useCellEdit.ts index 791d70aa..95dc71a7 100644 --- a/src/hooks/useCellEdit.ts +++ b/src/hooks/useCellEdit.ts @@ -7,7 +7,8 @@ export interface CellEditApi { editValue: string; setEditValue: React.Dispatch>; editError: string | null; - inputRef: React.RefObject; + inputRef: React.MutableRefObject; + customInputRef: React.RefCallback; seedValue: () => string; startEdit: () => void; cancelEdit: () => void; @@ -26,7 +27,10 @@ export default function useCellEdit(column: TableColumn, row: T, rowIndex: const [editing, setEditing] = React.useState(false); const [editValue, setEditValue] = React.useState(''); const [editError, setEditError] = React.useState(null); - const inputRef = React.useRef(null); + const inputRef = React.useRef(null); + const customInputRef = React.useCallback>(el => { + inputRef.current = el; + }, []); const seedValue = React.useCallback((): string => { const raw = column.selector ? column.selector(row, rowIndex) : undefined; @@ -92,6 +96,7 @@ export default function useCellEdit(column: TableColumn, row: T, rowIndex: setEditValue, editError, inputRef, + customInputRef, seedValue, startEdit, cancelEdit, diff --git a/src/types.ts b/src/types.ts index 25085c85..e40bd322 100644 --- a/src/types.ts +++ b/src/types.ts @@ -418,6 +418,10 @@ export interface CustomCellEditorContext { cancel: () => void; /** The column definition. */ column: TableColumn; + /** Current validation error from `validate`, or `null`. Set when a commit was rejected with a message. */ + error: string | null; + /** Attach as `ref` on your focusable element to opt in to auto-focus on open and refocus after a rejected commit. */ + inputRef: React.RefCallback; } /** Options for inline cell editors. */