diff --git a/.changeset/brisk-cells-type.md b/.changeset/brisk-cells-type.md new file mode 100644 index 000000000..e89d5f45c --- /dev/null +++ b/.changeset/brisk-cells-type.md @@ -0,0 +1,31 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add inline cell editing to `DataTable`. Give a typed column an `edit` config and users can change its values right in the list: `text`, `number` and `money` cells are typed into; `text` / `link` columns with `edit.options` and `badge` columns become dropdowns you can type into to search; `date` columns open a calendar. Editable columns show a pen after their title, and each cell shows a pen, chevron or calendar icon on hover. Rules (`min`, `max`, `maxDecimals`, `required`, `validate`) are checked when the user saves; a value that breaks one stays on screen, outlined in orange, and is never saved until it's fixed or undone. Per-row control (`canEdit(row, { selected })`) and Enter / Tab keyboard entry come built in. The value reaches `edit.onCommit(row, value)` when the user leaves the cell or picks a choice; return a promise to autosave, and the cell reverts if it rejects. Leaving the page with unsaved edits asks first. + +```tsx +column({ + id: "received", + label: "Received", + type: "number", + edit: { + canEdit: (row, { selected }) => selected, + min: 0, + maxDecimals: 0, // whole numbers only + onCommit: (row, value) => updateLine(row.id, { received: value }), + }, +}); + +column({ + id: "supplierId", + label: "Supplier", + type: "text", + edit: { + options: suppliers.map((s) => ({ value: s.id, label: s.name })), + onCommit: (row, value) => updateLine(row.id, { supplierId: value }), + }, +}); +``` + +Also fixes date display: a date-only `"YYYY-MM-DD"` value now shows that day in every time zone instead of the day before west of UTC. A `number` / `money` column with `edit.maxDecimals` displays up to that many decimals, and a `number` column whose `maxDecimals` is below its `minDecimals` no longer throws. diff --git a/.changeset/snug-lists-breathe.md b/.changeset/snug-lists-breathe.md new file mode 100644 index 000000000..3334ec3ad --- /dev/null +++ b/.changeset/snug-lists-breathe.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/app-shell": patch +--- + +`Select`'s dropdown now has a 4px inner gap, like `Menu`, `Combobox` and `Autocomplete`, so the highlighted option no longer touches the dropdown's edges. diff --git a/decisions/data-table-inline-editing.md b/decisions/data-table-inline-editing.md new file mode 100644 index 000000000..2e203535e --- /dev/null +++ b/decisions/data-table-inline-editing.md @@ -0,0 +1,187 @@ +# Decision: inline cell editing in DataTable + +> Status: **Open — for team input.** Every column type is implemented in this PR (text, number, money, link, dropdowns via `edit.options`, badge dropdowns and dates), so the proposal can be tried in the vite example (`/showcase/data-table-lab`) while the questions below are settled. +> +> Context: [platform-planning#1750](https://github.com/tailor-inc/platform-planning/issues/1750) (the UI Catalogue inline-edit pattern, left with @itsprade), the Larson IMS request in Slack (`#prj-larson-ims`), and [platform-planning#1428](https://github.com/tailor-inc/platform-planning/issues/1428). Related: [platform-planning#1115](https://github.com/tailor-inc/platform-planning/issues/1115) (optimistic rows) and [platform-planning#1161](https://github.com/tailor-inc/platform-planning/issues/1161) (LineItems). +> +> Scope: editing values in rows a DataTable already shows. Adding, removing or reordering rows is out of scope; that's document line items (#1161). + +## Problem + +Two teams have asked for the same thing: + +- **Larson IMS (Slack).** They're moving about a dozen AG-Grid tables to `DataTable`. Regular lists already work, including filtering, sorting, pagination and row actions. The blocked tables are the ones where people fill in quantities: purchase orders, invoices, receipts, credit notes and stock adjustments. They need two things: + 1. **Typing into a cell, with basic rules**, such as whole numbers only, no negatives, or at most 2 decimal places. + 2. **Some rows editable and others not**, decided by their own logic. For example, a row becomes editable once it's selected, or only while it meets a condition. +- **Opportunity tracker ([#1428](https://github.com/tailor-inc/platform-planning/issues/1428)).** They hand-built popover + save + refetch cells for text, enum and decimal values. They're asking for one standard version, so every app looks and saves the same way. + +Today DataTable only displays data. An app that needs editing draws its own inputs in `render`. That loses DataTable's typed formatting and fights with its row click, cell context menu and truncation. + +## What exists today + +- **DataTable is display-only.** Rows belong to the screen: DataTable renders `data.rows` as given and leaves sorting, filtering and paging to `CollectionControl`. There is no notion of cell focus or keyboard movement. +- **UI Catalogue pattern: "DataTable inline cell edit"** ([ui.tailor.tech](https://ui.tailor.tech/patterns/datatable-inline-edit), [#1750](https://github.com/tailor-inc/platform-planning/issues/1750)). It's built on DataTable `render`: + - Underlined values open a small popover with the input and Cancel / Save. + - Status is a badge plus a chevron menu; picking an option applies it immediately and shows a toast. + - A "Confirmed" flag commits through a checkbox. + - Its guidance: it suits a few edits on small lists. Use a Sheet or form for cross-field checks or large row counts. +- **The `list-dense-scan` pattern rules it out.** `docs-src/patterns/list-dense-scan.docs.outline.md` lines 32 and 89 say: "Inline editable cells — use pattern/detail or pattern/form/modal instead". +- **LineItems** ([#1161](https://github.com/tailor-inc/platform-planning/issues/1161), unmerged app-shell#238) is a document-line editor. It has cell editors but no validation rules, no per-row control, and no filtering or paging. That makes it a reference, not the base for list tables. +- **[#1115](https://github.com/tailor-inc/platform-planning/issues/1115)**: DataTable manages rendering state, and saving with optimistic updates and rollback belongs in a separate `useOptimisticRows` hook. The mutation callbacks were removed from `useDataTable` in app-shell#130. +- **`CsvImporter`**'s review grid, with `onCellEdit(row, columnKey, value)`, is the only inline editing in the package today. + +## Proposal + +Build inline editing into DataTable and configure it per column. + +1. **Type straight into a cell.** Click or Tab into an editable cell and type. There's no popover and no form. This is where the proposal differs from the catalogue pattern (see [Alternatives](#alternatives-considered)). +2. **Show what's editable — spreadsheet-style, with an icon.** + - Editable cells look like the rest of the table (no input boxes). An editable column has a pen after its title, so it can be told apart without hovering, on touch screens too. Hovering a cell shows its editor's icon at the right edge: a pen for typing, a chevron for dropdowns, a calendar for dates. The icon is only a cue: a click anywhere in the cell edits it. + - The cursor agrees: a text cursor for typing cells, a pointer for dropdown and date cells, and "not allowed" for cells that can't be edited. + - The cell being edited outlines its edges. + - Values stop before the icon, and row height and column width don't shift when a row becomes editable. +3. **Check rules on save, not while typing.** + - Anything can be typed. Rules are checked when the user presses Enter or Tab or leaves the cell, so a half-typed value is never flagged. + - Pasted and IME text is cleaned up (thousands separators, full-width digits) and then checked. It is never silently rounded. +4. **Explain broken rules, and never lose what was typed.** + - A broken rule — a required value, a maximum, or the screen's own rule ("can't receive more than ordered") — turns the cell red, with a tooltip saying why. Enter and Tab stay in the cell until it's fixed, and Esc puts the old value back. + - Clicking away from an invalid cell keeps the value on screen, outlined in orange, with its message on hover. It's never saved until the user fixes it or presses Esc. +5. **Let the screen decide which rows are editable.** The per-row check also knows whether the row is selected, so "editable once ticked" and "only while Draft" are one line each. +6. **Make keyboard data entry fast.** + - Enter saves and moves down to the same column. + - Tab saves and moves to the next editable cell, skipping read-only ones. + - Esc undoes. + - In a dropdown, typing filters the choices: Enter picks the highlighted one, Tab takes it and moves on. + - Japanese IME input is respected: pressing Enter to confirm a conversion doesn't save. +7. **Autosave on leave.** + - A value saves when the user leaves the cell or presses Enter or Tab, and only if it actually changed: going from `10` to `10.00` is not a change. + - Autosave is the default: there is no Save button and no saving indicator. + - If the screen's save fails, the cell goes back to the old value, and the screen can show its own toast. + - Leaving the page while something is unsaved — a cell mid-edit, a kept invalid value, or a save still in flight — asks first. A save in flight is waited for, so the prompt only appears when something can't be saved. + - Screens that hold edits for a Save button work the same way; their save just never fails. +8. **Leave everything else alone.** Filtering, sorting, paging, selection, row actions, pinned columns and column settings keep working. Existing tables don't change unless a column opts in. + +### Every column type + +| Column type | How it's edited | What the screen receives | +| ---------------- | ------------------------------------------------------------ | ----------------------------------------------------------------- | +| `text` | Type in the cell | Text, or `null` when emptied | +| `number` | Type in the cell, with the rules above | A number, or `null` | +| `money` | Same as number; decimals follow the currency (USD 2, JPY 0) | A number, or `null` | +| `date` | Pick from a calendar or type; date-time columns add the time | `"YYYY-MM-DD"`, a UTC ISO string for date-time columns, or `null` | +| `badge` (status) | Pick from a list, either one value or several | The value (or list of values), or `null` | +| `link` | Edit the text; it shows as plain text while editable | Text, or `null` | + +## API sketch + +This follows the shapes DataTable already has: + +- **`edit` sits on the column next to `type` / `typeOptions`** and narrows by `type`, like `typeOptions` and `accessor` do today (`packages/core/src/components/data-table/types.ts`). That means the value handed to `onCommit` has the right type for each column type. +- **`canEdit(row, { selected })`** mirrors `rowExpansion.canExpand(row)` and `RowAction.isDisabled(row)`. +- **Nothing changes in the `useDataTable` options or `DataTableContextValue`.** + +```tsx +const { column } = createColumnHelper(); + +column({ + id: "received", + label: "Received", + type: "number", + edit: { + canEdit: (row, { selected }) => selected && row.status === "open", + min: 0, + maxDecimals: 0, // whole numbers only + required: true, + validate: (value, row) => + value !== null && value > row.ordered ? `Can't exceed ordered (${row.ordered})` : undefined, + // May return a promise; if it rejects, the cell reverts. + onCommit: (row, value) => saveReceivedQty(row.id, value), + }, +}); +``` + +- **The table never stores edits.** It calls `onCommit`, and the screen updates `data`: a local draft for a Save-button screen, or a mutation for autosave. While an `onCommit` promise is pending, the cell keeps showing the new value; if the promise rejects, the cell reverts. Richer optimistic behaviour stays with `useOptimisticRows` (#1115). +- **Rules by type:** + - `text` / `link`: `required` and `validate` only. + - `number` / `money`: `min`, `max` and `maxDecimals`. `maxDecimals` defaults to what the column displays; for money, the currency's decimals. + - `date`: `min` and `max`. + - `badge`: `options` (defaults to the column's enum filter options) and `multiple`. +- **Built from existing components:** `Input`, `DatePicker`, `Select`, `Tooltip` and `Badge`. There are no new dependencies. Rows need an `id` to be editable. + +## Scope contract + +- **Required behaviour:** Larson IMS's received-quantity column works on a paginated, filtered DataTable. It must be integer, ≥ 0 and ≤ ordered, editable only when the row is selected, entered row by row with Enter, and autosaved with a revert on failure. +- **Compatibility:** + - No change to existing tables, the `useDataTable` options or the context. `edit` is optional on every column. + - One display fix comes with it: a number column shows as many decimals as its editor allows. Today an unconfigured number column rounds to whole numbers, so a typed 2.5 would render as "3". +- **Intentionally unsupported in v1:** + - yes/no flags (there's no boolean column type) + - a bring-your-own editor for custom `render` columns + - copying and pasting across several cells, fill-down, and undo + - moving between cells with the arrow keys + - a "changed" marker + - a saving indicator + - comma decimal separators (`1,5`) + - adding or removing rows +- **Validation plan:** + - Unit tests for parsing and rules. + - Interaction tests for each column type: commit, revert, blocked keys, keyboard flow, per-row control and no-op saves. + - Type tests for the value each column type receives. + - A goods-receipt demo in the vite example, checked in the browser, including that row height stays fixed. + +## Alternatives considered + +- **Keep it a pattern (#1750 as it is).** No package change, but every app rebuilds the rules, per-row checks and keyboard flow, which is the per-app drift #1428 describes. +- **Popover editing, like the catalogue pattern.** This matches the existing pattern and suits occasional changes. But a click and a Save for every cell is too slow for filling in quantities down a column, which is what Larson IMS needs. +- **DataTable holds the drafts** (dirty tracking, "get changes"). This goes against #1115's direction that DataTable only renders. The screen already owns the rows. +- **Build on LineItems.** It's unmerged, it has no validation or per-row control, and it has no filtering or paging. + +## Delivery + +- **PR 1, which unblocks Larson IMS:** + - text, number, money and link editing + - rules and errors + - per-row control + - keyboard entry + - autosave and revert + - docs (`docs-src/components/data-table.docs.outline.md`) + - pattern updates: rewrite `list-dense-scan`, and move the #1750 inline-edit pattern into `docs-src/patterns/` + - a vite example + - a `minor` changeset +- **PR 2 (folded in):** date and badge editing, plus dropdowns for `text` / `link` columns via `edit.options`, with the matching docs and example additions — built in the same PR so every column type can be reviewed together. + +## For the call + +- **Naming.** `edit` / `canEdit` / `onCommit`, or Base UI's `onValueCommitted` wording? +- ~~**Leaving an invalid cell reverts it.**~~ Decided in review: the value is kept, outlined in orange, and never saved until it's fixed or undone (see [Review round 1](#review-round-1-2026-10-05)). +- **Enter moves down** to the same column, spreadsheet-style, instead of staying put. OK? +- **One PR.** Date and badge editing were planned as a follow-up but are folded in, so every column type is reviewed together. OK, or split them back out? +- **#1115.** Ship the simple pending/revert behaviour with PR 1 and let `useOptimisticRows` build on it later, or wait for #1115? +- **Catalogue pattern.** Move #1750 into `docs-src/patterns/`, rewritten around the built-in feature, and retire the popover version? +- **Bring-your-own editor** (for flags, product search and similar): leave it out until a team asks? +- **Out of scope here.** The two open notes in `docs-src/pages/document-detail.docs.outline.md`, "a shared line-items component" and "in-place editing versus an edit route", stay open. This proposal covers list tables only. + +## Review round 1 (2026-10-05) + +Review comments from IzumiSy and Sean on app-shell#560, split into experience calls (made by @itsprade) and technical calls (for the team). + +**Experience — decided and built in this PR:** + +- **Editable columns are recognisable at rest** (Sean, with platform-planning#1563): a pen after the column title (50% opacity), and each cell shows its editor's icon — pen, chevron or calendar — on hover. A click anywhere in the cell edits it. +- **Autosave is the default**, with no Save button. Leaving the page with unsaved edits asks first (Sean's two save patterns; the batch-and-save mode stays a technical call below). +- **An invalid value is kept, outlined in orange, with its message** instead of being reverted when the user clicks away (Sean). +- **Rules are checked only when the user leaves the cell or saves**, custom `validate` rules included (Sean). +- **No keys are blocked while typing**; the rules judge the value on save, with the same red outline and tooltip (follows from IzumiSy's schema proposal). +- **Dropdowns are typable**: typing filters the choices. + +**Experience — still open:** what an edited row does when it no longer matches the current sort or filter. Research across Airtable, Excel, Google Sheets, Smartsheet, AG Grid, MUI X, Salesforce, SAP Fiori, Odoo, NetSuite, Retool and others found five patterns: stay put until refresh (the most common, and AG Grid's default), hide when the user moves on, live re-apply, pin and explain, and an explicit edit mode. The leading option for this table is to keep the row in place, mark rows that no longer match ("Not in view — hidden on refresh"), and offer a table-level "Refresh". An explicit edit mode is worth it only for document-style flows that are posted as a set (draft PO lines, a stock count). + +**Technical — for the team:** + +1. **Rules API** (IzumiSy): replace `min` / `max` / `maxDecimals` / `required` / `validate` with one `edit.schema` (Standard Schema, as `CsvImporter` does), and settle the empty-value, parsing, change-detection, async, row-context, stale-row, multiple-issue and picker-bounds questions IzumiSy listed. +2. **Save API** (Sean): a table-level edit setting with a mode (autosave, or collect then save), one save handler or hook instead of `onCommit` per column, `onCommit` returning the updated row or the table applying the value itself, and how it fits `useOptimisticRows` (#1115). +3. **Rapid edits** (Sean): a quick second edit must never save a row that's missing the first edit. +4. **Row editability set once** for the table instead of in every column's `canEdit` (Sean). +5. **Choices API** (Sean): match `Select`'s `items` / `mapItem` (and its planned changes) instead of `options`. +6. **Keeping data fresh** (Sean): refetching after saves, and row / bulk actions acting on stale data after a failed save. +7. **When to merge**: hold this PR until 1 and 2 are settled, or merge and accept a breaking change later. diff --git a/docs-manifest.json b/docs-manifest.json index 06036a7e7..f416b0411 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -446,10 +446,10 @@ "withURLCollectionState" ], "hashes": { - "typeSurface": "fe43108015be4a00", - "outline": "dc0b82256114191b", + "typeSurface": "79fb32871548dd96", + "outline": "871bd92ee8a9669f", "snapshot": null, - "outputMd": "3e8474e4d1437c91", + "outputMd": "01ed9bb2d78beef4", "examples": null } }, @@ -998,9 +998,9 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "68653902d2c9f861", + "outline": "87e010d622577e6e", "snapshot": null, - "outputMd": "916baa3cd2b9cbf1", + "outputMd": "e87e4247bcc61a99", "examples": "c0a2bd60e8a04474" } }, @@ -1169,7 +1169,7 @@ "hashes": { "typeSurface": "429710973dfa912b", "outline": "721e8995f53f0e8f", - "snapshot": "0ceac9786fb4ce3c", + "snapshot": "ad715a55f78adebd", "outputMd": "ae287e2bb5abc1c6", "examples": "1ce89cec30045466" } @@ -1693,7 +1693,7 @@ "packages/core/skills/app-shell-patterns/references/components/combobox.md": "dfb775c7c4307409", "packages/core/skills/app-shell-patterns/references/components/command-palette.md": "7e5bc675a593791a", "packages/core/skills/app-shell-patterns/references/components/csv-importer.md": "53d3c795ca21b9dc", - "packages/core/skills/app-shell-patterns/references/components/data-table.md": "3e8474e4d1437c91", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "01ed9bb2d78beef4", "packages/core/skills/app-shell-patterns/references/components/date-picker.md": "f39002ad79625dc4", "packages/core/skills/app-shell-patterns/references/components/default-header.md": "375e55f4b12fa2be", "packages/core/skills/app-shell-patterns/references/components/default-sidebar.md": "3422388a0cc78571", @@ -1754,9 +1754,9 @@ "packages/core/skills/app-shell-patterns/references/patterns/interaction-confirm.md": "2db70f423c6fdf97", "packages/core/skills/app-shell-patterns/references/patterns/interaction-multi-select.md": "770b33ada6b9317f", "packages/core/skills/app-shell-patterns/references/patterns/interaction-toast.md": "f68bdab855730f48", - "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "916baa3cd2b9cbf1", + "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "e87e4247bcc61a99", "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "717ef4e7ae835aff", "packages/core/skills/app-shell-patterns/references/migrations.md": "013c3fef2303b096", - "packages/core/skills/app-shell-patterns/SKILL.md": "526decbfd2db6555" + "packages/core/skills/app-shell-patterns/SKILL.md": "597d6501b54e4256" } } diff --git a/docs-src/components/data-table.docs.outline.md b/docs-src/components/data-table.docs.outline.md index 8e4b23a06..e9b769387 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -2,7 +2,7 @@ kind: code-backed group: data-table title: DataTable -description: Compound data table component with sortable columns, filter chips, cursor-based pagination, row actions, and multi-row selection +description: Compound data table component with sortable columns, filter chips, cursor-based pagination, row actions, multi-row selection, and inline cell editing sources: - packages/core/src/components/data-table/** - packages/core/src/hooks/use-collection-variables.ts @@ -376,6 +376,117 @@ The trigger is a native `