diff --git a/app/config.ts b/app/config.ts index 995e6cba..eaf5921f 100644 --- a/app/config.ts +++ b/app/config.ts @@ -252,6 +252,11 @@ export const ROUTES: RouteInfo[] = [ title: 'Complex usage scenario', description: `Example: a complex usage scenario for ${PRODUCT_NAME} featuring custom column definitions, asynchronous data loading with React Query, sorting, pagination, custom cell rendering, multiple row selection, and more`, }, + { + href: '/examples/inline-editing', + title: 'Inline editing', + description: `Example: inline editing with ${PRODUCT_NAME}`, + }, { href: '/type-definitions', title: 'Type definitions', diff --git a/app/examples/inline-editing/InlineEditingExample.tsx b/app/examples/inline-editing/InlineEditingExample.tsx new file mode 100644 index 00000000..19d3a2b0 --- /dev/null +++ b/app/examples/inline-editing/InlineEditingExample.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { DataTable } from '__PACKAGE__'; +import { useState } from 'react'; +import companies from '~/data/companies.json'; + +const records = companies.slice(0, 5); + +export function InlineEditingExample() { + const [data, setData] = useState(records); + + return ( + { + const newData = [...data]; + newData[index] = record; + setData(newData); + }, + }, + { accessor: 'streetAddress' }, + { accessor: 'city' }, + { accessor: 'state' }, + ]} + records={data} + /> + ); +} diff --git a/app/examples/inline-editing/page.tsx b/app/examples/inline-editing/page.tsx new file mode 100644 index 00000000..04522ab6 --- /dev/null +++ b/app/examples/inline-editing/page.tsx @@ -0,0 +1,39 @@ +import type { Route } from 'next'; +import { PRODUCT_NAME } from '~/app/config'; +import { CodeBlock } from '~/components/CodeBlock'; +import { PageNavigation } from '~/components/PageNavigation'; +import { PageTitle } from '~/components/PageTitle'; +import { Txt } from '~/components/Txt'; +import { readCodeFile } from '~/lib/code'; +import { allPromiseProps, getRouteMetadata } from '~/lib/utils'; +import { InlineEditingExample } from './InlineEditingExample'; + +const PATH: Route = '/examples/inline-editing'; + +export const metadata = getRouteMetadata(PATH); + +export default async function InlineEditingExamplePage() { + const code = await allPromiseProps({ + 'InlineEditingExample.tsx': readCodeFile(`${PATH}/InlineEditingExample.tsx`), + 'companies.json': readCodeFile('/../data/companies.json'), + }); + + return ( + <> + + + This example demonstrates how to implement inline cell editing in {PRODUCT_NAME}. + This is achieved by setting the editable property to true in the column definition. + Additionally, the onEdit callback is provided to handle updates to the record when the cell value is changed. + In this example, we allow editing of the name field of company records. + + This is baked in to the DataTable component for the column definitions, so no additional libraries are required. However, + this only supports the basic single cell editing scenario, for a more complex case of editing the entire row or adding validation it is still recommended to + implement the logic yourself by changing the logic of the render function of the column to show input fields when in edit mode. + + + + + + ); +} diff --git a/package/DataTableRow.tsx b/package/DataTableRow.tsx index 596e454e..11d50ed1 100644 --- a/package/DataTableRow.tsx +++ b/package/DataTableRow.tsx @@ -115,6 +115,8 @@ export function DataTableRow({ cellsClassName, cellsStyle, customCellAttributes, + editable, + onEdit, } = { ...defaultColumnProps, ...columnProps }; return ( @@ -148,6 +150,8 @@ export function DataTableRow({ render={render} defaultRender={defaultColumnRender} customCellAttributes={customCellAttributes} + editable={editable} + onEdit={onEdit} /> ); })} diff --git a/package/DataTableRowCell.tsx b/package/DataTableRowCell.tsx index d5616846..0db26281 100644 --- a/package/DataTableRowCell.tsx +++ b/package/DataTableRowCell.tsx @@ -1,5 +1,6 @@ -import { TableTd, type MantineStyleProp } from '@mantine/core'; +import { TableTd, TextInput, type MantineStyleProp } from '@mantine/core'; import clsx from 'clsx'; +import { useState } from 'react'; import { useMediaQueryStringOrFunction } from './hooks'; import type { DataTableColumn } from './types'; import { @@ -19,14 +20,23 @@ type DataTableRowCellProps = { record: T; index: number; defaultRender: - | ((record: T, index: number, accessor: keyof T | (string & NonNullable)) => React.ReactNode) - | undefined; + | ((record: T, index: number, accessor: keyof T | (string & NonNullable)) => React.ReactNode) + | undefined; onClick: React.MouseEventHandler | undefined; onDoubleClick: React.MouseEventHandler | undefined; onContextMenu: React.MouseEventHandler | undefined; } & Pick< DataTableColumn, - 'accessor' | 'visibleMediaQuery' | 'textAlign' | 'width' | 'noWrap' | 'ellipsis' | 'render' | 'customCellAttributes' + | 'accessor' + | 'visibleMediaQuery' + | 'textAlign' + | 'width' + | 'noWrap' + | 'ellipsis' + | 'render' + | 'customCellAttributes' + | 'editable' + | 'onEdit' >; export function DataTableRowCell({ @@ -46,15 +56,46 @@ export function DataTableRowCell({ render, defaultRender, customCellAttributes, + editable, + onEdit, }: DataTableRowCellProps) { + const [isEditing, setIsEditing] = useState(false); + const [editedValue, setEditedValue] = useState(''); + if (!useMediaQueryStringOrFunction(visibleMediaQuery)) return null; + + const handleEdit = () => { + if (onEdit) { + const newRecord = { ...record, [accessor as keyof T]: editedValue }; + onEdit(newRecord, index); + } + setIsEditing(false); + }; + + const handleClick = (e: React.MouseEvent) => { + if (editable) { + setIsEditing(true); + setEditedValue(getValueAtPath(record, accessor) as string); + } + onClick?.(e); + }; + + let cellContent: React.ReactNode; + if (render) { + cellContent = render(record, index); + } else if (defaultRender) { + cellContent = defaultRender(record, index, accessor); + } else { + cellContent = getValueAtPath(record, accessor) as React.ReactNode; + } + return ( ({ }, style, ]} - onClick={onClick} + onClick={handleClick} onDoubleClick={onDoubleClick} onContextMenu={onContextMenu} {...customCellAttributes?.(record, index)} > - {render - ? render(record, index) - : defaultRender - ? defaultRender(record, index, accessor) - : (getValueAtPath(record, accessor) as React.ReactNode)} + {isEditing ? ( + setEditedValue(event.currentTarget.value)} + onBlur={handleEdit} + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleEdit(); + } + if (event.key === 'Escape') { + setIsEditing(false); + } + }} + autoFocus + + /> + ) : ( + cellContent + )} ); } diff --git a/package/types/DataTableColumn.ts b/package/types/DataTableColumn.ts index ff3d32b0..a1a0f88c 100644 --- a/package/types/DataTableColumn.ts +++ b/package/types/DataTableColumn.ts @@ -167,7 +167,26 @@ export type DataTableColumn> = { */ footerStyle?: MantineStyleProp; } & ( - | { + | { + /** + * If true, the cells in this column will be editable. + */ + editable?: false; + onEdit?: never; + } + | { + /** + * If true, the cells in this column will be editable. + */ + editable: true; + /** + * Callback fired when a cell in this column is edited. + * Receives the edited record and its index as arguments. + */ + onEdit: (record: T, index: number) => void; + } + ) & ( + | { /** * If true, cell content in this column will be truncated with ellipsis as needed and will not wrap * to multiple lines (i.e. `overflow: hidden; text-overflow: ellipsis`; `white-space: nowrap`). @@ -177,7 +196,7 @@ export type DataTableColumn> = { noWrap?: never; } - | { + | { ellipsis?: never; /** @@ -187,4 +206,4 @@ export type DataTableColumn> = { */ noWrap?: boolean; } -); + );