Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
31 changes: 31 additions & 0 deletions .changeset/brisk-cells-type.md
Original file line number Diff line number Diff line change
@@ -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 }),
},
Comment on lines +12 to +17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we avoid exposing min, max, maxDecimals, required, and a separate validate callback here?

I would prefer edit.schema to accept a Standard Schema-compatible validator, following the existing CsvImporter pattern. That gives consumers one validation and normalization point, rather than adding DataTable-specific validation rules as public API.

For example, using Zod purely as a Standard Schema-compatible implementation (DataTable itself would not need a Zod dependency):

import { z } from "zod";

const receivedQuantity = z.number().int().min(0);

column({
  id: "received",
  label: "Received",
  type: "number",
  edit: {
    schema: receivedQuantity,
    onCommit: (row, value) => updateLine(row.id, { received: value }),
  },
});

The same mechanism can normalize a value before it is committed:

const normalizedText = z
  .string()
  .transform((value) => value.normalize("NFKC").trim());

I think the table should keep the raw draft untouched while the user is typing, then parse it for the column type and run the schema on Enter, Tab, or blur. On success, it should commit the schema output; on failure, it should leave the external value unchanged and surface the schema issue. In particular, we should not transform on every keystroke, since that tends to break IME composition and cursor position.

Before settling that contract, I think we should explicitly resolve the following:

  1. Input, output, and empty values: what does the schema receive for an empty cell, and must its output remain compatible with the column's value type?
  2. Parsing boundary: does DataTable reject syntactically invalid number/date input before schema validation, or does the schema own parsing/coercion as well?
  3. Change detection: compare the current value against the transformed/canonical schema output, not the raw draft, so normalization alone does not cause a save.
  4. Async schemas: Standard Schema validation may be async. We need pending behavior and protection against stale validation results; Enter/Tab should only move after the latest successful validation.
  5. Validation vs persistence errors: a schema failure means “fix this value”; an onCommit/server failure means “the value could not be saved.” These should not be presented as the same state.
  6. Row context: the current callback receives row. If row-dependent rules remain supported, we need to decide how that context reaches the schema (for example, schema: (row) => schema).
  7. External updates: decide what happens if the row changes while a draft, validation, or save is pending. We should not commit a result derived from stale row data.
  8. Schema issues and accessibility: schemas can return multiple issues, while a cell needs a clear message, aria-invalid, and an associated accessible error description.
  9. Pickers and constrained values: dropdown options and date bounds remain UI affordances, but schema validation must be the final authority when options or constraints change.

This is a larger API decision than replacing validate, but I think it produces a cleaner and more reusable public contract than adding the individual options.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I think an as-you-type filter – for e.g., to not allow a-z for numeric inputs – makes sense.

  2. I think we treat empty as a value. If the field is optional, this sets the field to null or empty-string. If the field is required, then it is a validation error

  3. "Enter/Tab should only move after the latest successful validation" — I like this! (Although if it is async and takes some time, this would add a lot of friction)

  4. This live inline editing is not an easy thing it seems. Would saving a field at a time be the wisest here: If I want to update the Order Date of a PO, then we only update that single field. Locking the whole row until the save finishes would be a poor user experience

  5. For reference, here is Google Sheets with a validation error

image

and AirTable
image

  1. I wonder if schema validation rules can be transformed into the appropriate inputs for ergonomic UI elements. But I agree it's a better to be able to enter an invalidate date but correctly be told it's invalid, than have it silently error.

I still have reservations about live-inline cell editing. A save button to commit the changes to me is a much simpler task, with a lot less prickly UX edges

});

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.
5 changes: 5 additions & 0 deletions .changeset/snug-lists-breathe.md
Original file line number Diff line number Diff line change
@@ -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.
187 changes: 187 additions & 0 deletions decisions/data-table-inline-editing.md

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions docs-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -446,10 +446,10 @@
"withURLCollectionState"
],
"hashes": {
"typeSurface": "fe43108015be4a00",
"outline": "dc0b82256114191b",
"typeSurface": "79fb32871548dd96",
"outline": "871bd92ee8a9669f",
"snapshot": null,
"outputMd": "3e8474e4d1437c91",
"outputMd": "01ed9bb2d78beef4",
"examples": null
}
},
Expand Down Expand Up @@ -998,9 +998,9 @@
"symbols": [],
"hashes": {
"typeSurface": null,
"outline": "68653902d2c9f861",
"outline": "87e010d622577e6e",
"snapshot": null,
"outputMd": "916baa3cd2b9cbf1",
"outputMd": "e87e4247bcc61a99",
"examples": "c0a2bd60e8a04474"
}
},
Expand Down Expand Up @@ -1169,7 +1169,7 @@
"hashes": {
"typeSurface": "429710973dfa912b",
"outline": "721e8995f53f0e8f",
"snapshot": "0ceac9786fb4ce3c",
"snapshot": "ad715a55f78adebd",
"outputMd": "ae287e2bb5abc1c6",
"examples": "1ce89cec30045466"
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
Loading