Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
67 changes: 67 additions & 0 deletions .changeset/quiet-cell-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
"@tailor-platform/app-shell": minor
---

Add built-in error popovers to DataTable inline editing. When `onValueChange` or `onValueCommitted` throws or returns a rejected Promise, the affected cell shows an error. Throw the exported `CellEditError` with a translated, user-facing message to display that text; other failures display the localized message “Could not confirm this change”.

Alongside this feedback, the editor now retains pending and failed drafts for correction or retry. Confirmation waits for any pending `onValueChange` check to succeed, and the draft clears only after confirmation succeeds. Drafts remain editable without trapping cell navigation; data adoption, persistence, and rollback remain consumer-owned.

Each new failure automatically opens its popover without moving focus. Closing the details retains the draft and error, which can be inspected again with the error icon or Alt+ArrowDown. Esc closes open details first; a subsequent Esc discards the draft and error together. The standard popover adds no reset action.

For example, reject negative quantities with a user-facing message. Invalid drafts remain editable; successful confirmation updates the consumer-owned rows:

```tsx
import { useState } from "react";
import {
CellEditError,
DataTable,
createColumnHelper,
useDataTable,
} from "@tailor-platform/app-shell";

type Item = { id: string; quantity: number | null };
const { column } = createColumnHelper<Item>();

function EditableItems() {
const [rows, setRows] = useState<Item[]>([{ id: "item-1", quantity: 5 }]);
const columns = [
column({
id: "quantity",
label: "Quantity",
type: "number",
edit: {
onValueCommitted: (row, value) => {
if (value !== null && value < 0) {
throw new CellEditError("Quantity must be zero or greater");
}
setRows((current) =>
current.map((item) => (item.id === row.id ? { ...item, quantity: value } : item)),
);
},
},
}),
];
const table = useDataTable({ columns, data: { rows } });

return (
<DataTable.Root value={table}>
<DataTable.Table />
</DataTable.Root>
);
}
```

The standard error popover needs no configuration. To customize its body or include consumer-owned actions, add `error.render` to the column's `edit` config. The renderer receives the current row and the original thrown value:

```tsx
error: {
render: ({ row, error }) => (
<div>
<p>Could not update item {row.id}</p>
<p>{error instanceof CellEditError ? error.message : "Please try again later"}</p>
</div>
),
},
```

If the callback handles its own failures and feedback instead, set `edit.error: false` to disable callback interception and retain eager edit completion.
9 changes: 5 additions & 4 deletions docs-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@
"symbols": [
"BadgeCellEditOptions",
"BuildQueryVariables",
"CellEditError",
"CellEditState",
"CollectionControl",
"CollectionControlProvider",
Expand Down Expand Up @@ -471,10 +472,10 @@
"withURLCollectionState"
],
"hashes": {
"typeSurface": "05cbf8a4bdb7a863",
"outline": "93a2a1e3c380f5b9",
"typeSurface": "33eb2bdd6be1c774",
"outline": "b4904d51b0a1924d",
"snapshot": "a97e067c66e46ef6",
"outputMd": "94d4e9340fdb20f3",
"outputMd": "281343a3ab023448",
"examples": "10abd548e6affb9d"
}
},
Expand Down Expand Up @@ -1786,7 +1787,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": "94d4e9340fdb20f3",
"packages/core/skills/app-shell-patterns/references/components/data-table.md": "281343a3ab023448",
"packages/core/skills/app-shell-patterns/references/components/date-picker.md": "bc69f14829004f9d",
"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
102 changes: 77 additions & 25 deletions docs-src/components/data-table.docs.outline.md

Large diffs are not rendered by default.

102 changes: 77 additions & 25 deletions docs/components/data-table.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { useRef, useState } from "react";
import {
Button,
CellEditError,
DataTable,
Text,
createColumnHelper,
useDataTable,
} from "@tailor-platform/app-shell";

type ErrorDemoRow = {
id: string;
name: string;
note: string | null;
quantity: number | null;
code: string | null;
};

const { column } = createColumnHelper<ErrorDemoRow>();

function checkNote(value: string | null) {
if (value?.includes("!!"))
throw new CellEditError(
"Notes can't contain repeated exclamation marks. Keep notes factual and send anything urgent to the receiving team through the comments panel so it is tracked against this purchase order.",
);
if (value?.includes("!")) throw new CellEditError("Remove ! from the note");
}

/** Callback failures use the table's default popover unless content is overridden. */
export function EditingErrorsDemo() {
const [rows, setRows] = useState<ErrorDemoRow[]>([
{ id: "error-demo", name: "Prototype row", note: "Ready", quantity: 5, code: "SKU-100" },
]);
const [savingCount, setSavingCount] = useState(0);
const [feedback, setFeedback] = useState("No action yet.");
const quantityRevision = useRef(0);
const update = (id: string, patch: Partial<ErrorDemoRow>) => {
setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
};

const columns = [
column({ id: "name", label: "Item", type: "text", width: 160 }),
column({
id: "note",
label: "Draft note",
type: "text",
width: 200,
edit: {
onValueChange: (_row, draft) => checkNote(draft),
onValueCommitted: (row, value) => update(row.id, { note: value }),
},
}),
column({
id: "quantity",
label: "Async quantity",
type: "number",
width: 160,
edit: {
onValueChange: () => {
quantityRevision.current += 1;
},
onValueCommitted: async (row, value) => {
const revision = ++quantityRevision.current;
setSavingCount((count) => count + 1);
try {
await new Promise<void>((resolve) => setTimeout(resolve, 600));
if (value === 13)
throw new CellEditError("Quantity 13 was rejected by the simulated server");
if (revision === quantityRevision.current) {
update(row.id, { quantity: value });
}
} finally {
setSavingCount((count) => count - 1);
}
},
},
}),
column({
id: "code",
label: "Custom code",
type: "text",
width: 180,
edit: {
onValueCommitted: (row, value) => {
if (!value?.startsWith("SKU-")) throw new CellEditError("Item code must start with SKU-");
update(row.id, { code: value });
},
error: {
render: ({ row, error }) => (
<div className="space-y-2">
<Text variant="label-md">Code issue for {row.name}</Text>
<Text variant="body-sm">
{error instanceof CellEditError ? error.message : "The code could not be accepted."}
</Text>
<Button
variant="outline"
size="sm"
onClick={() => setFeedback("Use a code beginning with SKU-, for example SKU-101.")}
>
Show valid code example
</Button>
</div>
),
},
},
}),
];
const table = useDataTable({ columns, data: { rows } });

return (
<div className="space-y-3">
<ul className="list-inside list-disc space-y-1 text-body-sm text-muted-foreground">
<li>
In <strong>Draft note</strong>, type <code>!</code> to throw from{" "}
<code>onValueChange</code>. The failed draft stays visible and cannot be confirmed until
you remove <code>!</code>. Type <code>!!</code> to see a longer message wrap. Error
details open automatically without moving focus. Close them with × or Esc; a subsequent
Esc restores the adopted note and clears its error.
</li>
<li>
Set <strong>Async quantity</strong> to <code>13</code> and press Enter. The returned
Promise rejects after 600 ms; <code>13</code> stays visible while pending and after
failure. Enter retries the same draft. Change it to <code>14</code> to save successfully,
or close its error details and press Esc to restore the adopted quantity. To test stale
failures, confirm 13 and start typing another value before the rejection arrives.
</li>
<li>
Set <strong>Custom code</strong> to <code>bad</code> and press Enter. Its error popover
opens automatically; press Alt+ArrowDown to focus the details and use{" "}
<strong>Show valid code example</strong> to try interactive custom content. The failed{" "}
<code>bad</code> draft remains editable; change it to <code>SKU-101</code> and confirm to
adopt a valid code.
</li>
</ul>
<Text variant="body-sm" className="text-muted-foreground">
Click an error icon or press Alt+ArrowDown in its editor to inspect the popover without
confirming or discarding the draft. Pending and failed drafts survive Tab or blur without
trapping focus. Only successful confirmation clears the draft; only the consumer adopts
successful values. The standard popover adds no reset action. Closing error details keeps
the draft; Esc in the editor cancels the draft and its error together.
</Text>
<DataTable.Root value={table}>
<DataTable.Table />
<DataTable.Footer>
<div className="space-y-1">
<Text variant="body-sm" className="text-muted-foreground" aria-live="polite">
Saves in flight: {savingCount}
</Text>
<Text variant="body-sm" render={<output />}>
{feedback}
</Text>
</div>
</DataTable.Footer>
</DataTable.Root>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
CellEditError,
DataTable,
aggregations,
createColumnHelper,
Expand Down Expand Up @@ -231,8 +232,7 @@ export function InlineEditingDemo() {
canEdit: (row) => row.status === "pending",
onValueCommitted: (row, value) => {
if (value === null) {
reject(`${row.id}: Supplier is required`);
return;
throw new CellEditError(`${row.id}: Supplier is required`);
}
update(row.id, { supplierId: value }, `Supplier → ${value}`);
},
Expand Down Expand Up @@ -268,8 +268,9 @@ export function InlineEditingDemo() {
canEdit: (_row, { selected }) => selected,
onValueCommitted: (row, value) => {
if (value !== null && (!Number.isInteger(value) || value < 0 || value > row.ordered)) {
reject(`${row.id}: Received must be a whole number from 0 to ${row.ordered}`);
return;
throw new CellEditError(
`${row.id}: Received must be a whole number from 0 to ${row.ordered}`,
);
}
update(row.id, { received: value }, `Received → ${value ?? "empty"}`);
},
Expand All @@ -285,10 +286,9 @@ export function InlineEditingDemo() {
onValueCommitted: (row, value) => {
const scale = row.currency === "JPY" ? 1 : 100;
if (value === null || value < 0 || Math.round(value * scale) / scale !== value) {
reject(
throw new CellEditError(
`${row.id}: Unit price is required, non-negative, and must fit ${row.currency} precision`,
);
return;
}
const request = priceSaves.current.get(row.id)!;
const revision = ++request.revision;
Expand Down Expand Up @@ -329,8 +329,7 @@ export function InlineEditingDemo() {
edit: {
onValueCommitted: (row, value) => {
if (value === null || value < 0 || value > 100 || Math.round(value * 10) / 10 !== value) {
reject(`${row.id}: Discount must be 0–100 with at most one decimal`);
return;
throw new CellEditError(`${row.id}: Discount must be 0–100 with at most one decimal`);
}
update(row.id, { discount: value }, `Discount → ${value}%`);
},
Expand Down Expand Up @@ -360,8 +359,9 @@ export function InlineEditingDemo() {
value !== null &&
(value < 0 || value > 500 || Math.round(value * 1000) / 1000 !== value)
) {
reject(`${row.id}: Weight must be 0–500 kg with at most three decimals`);
return;
throw new CellEditError(
`${row.id}: Weight must be 0–500 kg with at most three decimals`,
);
}
update(row.id, { weightKg: value }, `Weight → ${value ?? "empty"} kg`);
},
Expand All @@ -375,8 +375,7 @@ export function InlineEditingDemo() {
edit: {
onValueCommitted: (row, value) => {
if (value === null || value.trim() === "") {
reject(`${row.id}: Lot no. is required`);
return;
throw new CellEditError(`${row.id}: Lot no. is required`);
}
update(row.id, { lot: value }, `Lot → ${value}`);
},
Expand All @@ -390,8 +389,7 @@ export function InlineEditingDemo() {
edit: {
onValueCommitted: (row, value) => {
if (value !== null && value < "2026-09-01") {
reject(`${row.id}: Expected must be September 2026 or later`);
return;
throw new CellEditError(`${row.id}: Expected must be September 2026 or later`);
}
update(row.id, { expected: value }, `Expected → ${value ?? "cleared"}`);
},
Expand All @@ -418,8 +416,7 @@ export function InlineEditingDemo() {
edit: {
onValueCommitted: (row, value) => {
if (value !== null && (value < "2026-10-01" || value > "2027-12-31")) {
reject(`${row.id}: Best before must be October 2026–December 2027`);
return;
throw new CellEditError(`${row.id}: Best before must be October 2026–December 2027`);
}
update(row.id, { bestBefore: value }, `Best before → ${value ?? "cleared"}`);
},
Expand Down Expand Up @@ -448,8 +445,7 @@ export function InlineEditingDemo() {
edit: {
onValueCommitted: (row, value) => {
if (value === null) {
reject(`${row.id}: Status is required`);
return;
throw new CellEditError(`${row.id}: Status is required`);
}
update(row.id, { status: value as LineStatus }, `Status → ${value}`);
},
Expand Down Expand Up @@ -498,9 +494,10 @@ export function InlineEditingDemo() {
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
<strong>Consumer-owned rules and saving.</strong> Editors notify on Enter, Tab, blur, or a
pick. This screen accepts or rejects the value: rejected business values return to the
existing data, without adding cell errors or blocking Tab navigation. Saving has no built-in
indicator, rollback, or leave-page prompt.
pick. Invalid business values throw <code>CellEditError</code>, retaining the draft with a
cell error without trapping Tab navigation. This screen owns saving indicators and
optimistic rollback; saving failures still use its own feedback. There is no leave-page
prompt.
</p>
{feedback && (
<p
Expand Down
Loading
Loading