Skip to content

Commit aa9fc51

Browse files
committed
feat(ui): explain refused inline-grid and form saves
The grid's save bar says a refusal whole, one line per reason, marks the cells the refusal names, and sits on the grid's inset; ObjectGrid checks the record form's field rules before sending an inline edit. A refused form submit is said once, by the banner brought into view. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibxQtKRyLyL6eK14pgY
1 parent 3ba5206 commit aa9fc51

5 files changed

Lines changed: 315 additions & 35 deletions

File tree

‎ui/packages/components/src/renderers/complex/data-table.tsx‎

Lines changed: 121 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { describeIgnoredBind, describeNonArrayData } from './dataTableBindDiagno
1818
import { ComponentRegistry, compareSortValues, evalRowPredicate, formatDate, hasDeclaredPredicate, isActionSwitchedOff, formatDateTime, fromDateTimeInputValue, getSortValue, isEmptyValue, isImpossibleStoredDay, toDateInputValue, toDateTimeInputValue } from '@object-ui/core';
1919
import type { DataTableSchema, TableColumn, TableSortItem, TableColumnType } from '@object-ui/types';
2020
import type { SortDirection } from '@objectstack/spec/shared';
21-
import { SchemaRenderer, toRenderableSchema, useCapabilityGate, useRowPredicate, usePredicateScope } from '@object-ui/react';
21+
import { SchemaRenderer, toRenderableSchema, useCapabilityGate, useRowPredicate, usePredicateScope, extractFieldErrors, unmarkedRefusalLines } from '@object-ui/react';
2222
import { createSafeTranslation, useDisplayLocale } from '@object-ui/i18n';
2323
import {
2424
Table,
@@ -374,6 +374,39 @@ function extractSaveErrorMessage(error: unknown): string {
374374
return typeof error === 'string' && error.trim() ? error.trim() : 'Unknown error';
375375
}
376376

377+
/**
378+
* The lines a refused save shows in the save bar: one per reason the refusals
379+
* list (a `VALIDATION_FAILED` answer keeps one `fields[]` entry per failed
380+
* rule), else each refusal's one message — distinct, in the order the rows
381+
* were sent. Said whole, never cut to one line (objectstack-ai/objectstack#27520).
382+
*/
383+
function saveRefusalLines(errors: readonly unknown[]): string[] {
384+
const lines: string[] = [];
385+
for (const error of errors) {
386+
const listed = unmarkedRefusalLines(error, () => false);
387+
for (const line of listed && listed.length > 0 ? listed : [extractSaveErrorMessage(error)]) {
388+
if (!lines.includes(line)) lines.push(line);
389+
}
390+
}
391+
return lines;
392+
}
393+
394+
/**
395+
* The cells a refused row save names, field → reason, read from the refusal's
396+
* own field entries (`extractFieldErrors`); `null` when it names none. A field
397+
* the table draws no column for simply marks nothing: its reason is still in
398+
* the save bar.
399+
*/
400+
function refusedCellsOf(error: unknown): Record<string, string> | null {
401+
const entries = extractFieldErrors(error);
402+
if (!entries) return null;
403+
const cells: Record<string, string> = {};
404+
for (const { field, message } of entries) {
405+
if (!(field in cells)) cells[field] = message || extractSaveErrorMessage(error);
406+
}
407+
return cells;
408+
}
409+
377410
/**
378411
* The element type of `DataTableSchema.rowActionDefs`. Derived via indexed
379412
* access rather than imported by name: `@object-ui/types` defines
@@ -1670,9 +1703,35 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
16701703
};
16711704
}, [pendingChanges]);
16721705
const [isSaving, setIsSaving] = useState(false);
1673-
// Last save failure message (server validation text, etc.) shown in the
1674-
// toolbar; null when the last save attempt succeeded or nothing's been saved.
1675-
const [saveError, setSaveError] = useState<string | null>(null);
1706+
// Last save failure, one line per reason (server validation text, etc.),
1707+
// shown in the toolbar; null when the last save attempt succeeded or
1708+
// nothing's been saved.
1709+
const [saveError, setSaveError] = useState<string[] | null>(null);
1710+
// The cells the last refusal named, per record key, field → reason
1711+
// (objectstack-ai/objectstack#27520): each is marked where it is drawn, and
1712+
// its mark goes the moment the cell is edited again or its row saves.
1713+
const [cellErrors, setCellErrors] = useState<Map<PendingRowKey, Record<string, string>>>(new Map());
1714+
const clearCellError = (rowKey: PendingRowKey, columnKey: string) => {
1715+
setCellErrors((prev) => {
1716+
const cells = prev.get(rowKey);
1717+
if (!cells || !(columnKey in cells)) return prev;
1718+
const next = new Map(prev);
1719+
const rest = { ...cells };
1720+
delete rest[columnKey];
1721+
if (Object.keys(rest).length > 0) next.set(rowKey, rest);
1722+
else next.delete(rowKey);
1723+
return next;
1724+
});
1725+
};
1726+
const setRowCellErrors = (rowKey: PendingRowKey, cells: Record<string, string> | null) => {
1727+
setCellErrors((prev) => {
1728+
if (!cells && !prev.has(rowKey)) return prev;
1729+
const next = new Map(prev);
1730+
if (cells) next.set(rowKey, cells);
1731+
else next.delete(rowKey);
1732+
return next;
1733+
});
1734+
};
16761735
// Records whose last save attempt failed, by the key their pending edit is
16771736
// kept under (objectui#12310) — tinted destructive so the author sees exactly
16781737
// which rows didn't persist (no silent "phantom save").
@@ -2335,6 +2394,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
23352394
// save that settled since this render is not undone (objectui#12322).
23362395
const inFlight = inFlightRef.current.get(rowKey);
23372396
setPendingChanges((prev) => stageCellChange(prev, rowKey, row, columnKey, valueToStage, inFlight));
2397+
clearCellError(rowKey, columnKey);
23382398

23392399
// Call the legacy onCellChange callback if provided
23402400
if (schema.onCellChange) {
@@ -2428,6 +2488,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
24282488
const inFlight = inFlightRef.current.get(rowKey);
24292489
setEditValue(value);
24302490
setPendingChanges((prev) => stageCellChange(prev, rowKey, row, columnKey, value, inFlight));
2491+
clearCellError(rowKey, columnKey);
24312492
};
24322493

24332494
// Commit the in-flight edit when the input loses focus (e.g. the user clicks
@@ -2502,12 +2563,15 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
25022563
if (next.size === 0) setSaveError(null);
25032564
return next;
25042565
});
2566+
setRowCellErrors(rowKey, null);
25052567
} catch (error) {
25062568
// Keep the pending change so the author can fix and retry; surface the
2507-
// reason instead of failing silently, and flag the row.
2569+
// reason instead of failing silently, flag the row, and mark the cells
2570+
// the refusal names.
25082571
console.error('Failed to save row:', error);
2509-
setSaveError(extractSaveErrorMessage(error));
2572+
setSaveError(saveRefusalLines([error]));
25102573
setErroredRows((prev) => new Set(prev).add(rowKey));
2574+
setRowCellErrors(rowKey, refusedCellsOf(error));
25112575
} finally {
25122576
setIsSaving(false);
25132577
}
@@ -2524,6 +2588,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
25242588
if (next.size === 0) setSaveError(null);
25252589
return next;
25262590
});
2591+
setRowCellErrors(rowKey, null);
25272592
};
25282593

25292594
// Save All. Like `saveRow`, it settles against the pending edits as they
@@ -2589,22 +2654,35 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
25892654
const landed = new Map(Array.from(sent).filter(([key]) => !failedKeys.has(key)));
25902655
setPendingChanges((prev) => removeSentEdits(prev, landed));
25912656
setErroredRows(failedKeys);
2592-
setSaveError(extractSaveErrorMessage(failed[0].error));
2657+
// Every refused row's reasons, and the cells each refusal names
2658+
// (objectstack-ai/objectstack#27520).
2659+
setSaveError(saveRefusalLines(failed.map((entry) => entry.error)));
2660+
const refusedCells = new Map<PendingRowKey, Record<string, string>>();
2661+
for (const entry of failed) {
2662+
const cells = refusedCellsOf(entry.error);
2663+
const key = keyOfRowIndex.get(entry.rowIndex);
2664+
if (cells && key !== undefined) refusedCells.set(key, cells);
2665+
}
2666+
setCellErrors(refusedCells);
25932667
return;
25942668
}
25952669
// Clear every edit the batch saved, and only those.
25962670
setPendingChanges((prev) => removeSentEdits(prev, sent));
25972671
// Saved — clear any prior errors.
25982672
setErroredRows(new Set());
2673+
setCellErrors(new Map());
25992674
setSaveError(null);
26002675
} catch (error) {
26012676
// A rejection says nothing about which rows landed, so it is read as
26022677
// all-or-nothing: keep every pending row, flag every row it sent (not
26032678
// one staged during the save), and surface the reason instead of
26042679
// failing silently.
26052680
console.error('Failed to save batch:', error);
2606-
setSaveError(extractSaveErrorMessage(error));
2681+
setSaveError(saveRefusalLines([error]));
26072682
setErroredRows(new Set(sent.keys()));
2683+
// Which row a whole-batch refusal's fields belong to is unknown, so no
2684+
// cell is marked; the save bar says every reason.
2685+
setCellErrors(new Map());
26082686
} finally {
26092687
setIsSaving(false);
26102688
}
@@ -2613,6 +2691,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
26132691
const cancelAllChanges = () => {
26142692
setPendingChanges(new Map());
26152693
setErroredRows(new Set());
2694+
setCellErrors(new Map());
26162695
setSaveError(null);
26172696
};
26182697

@@ -3022,7 +3101,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
30223101
<div className={`flex flex-col h-full gap-2 sm:gap-4 ${className || ''}`}>
30233102
{/* Toolbar */}
30243103
{showToolbar && (
3025-
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 sm:gap-4 flex-none">
3104+
<div data-slot="table-toolbar" className="flex flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center justify-between gap-2 sm:gap-4 flex-none">
30263105
<div className="flex items-center gap-2 flex-1">
30273106
{searchEnabled && (
30283107
<div className="relative w-full sm:max-w-sm flex-1">
@@ -3040,17 +3119,6 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
30403119
<div className="flex flex-wrap items-center gap-2">
30413120
{hasPendingChanges && (
30423121
<>
3043-
{saveError && (
3044-
<div
3045-
role="alert"
3046-
className="flex items-center gap-1.5 text-sm text-destructive max-w-[16rem] sm:max-w-sm"
3047-
>
3048-
<AlertCircle className="h-4 w-4 flex-none" />
3049-
<span className="truncate" title={`${t('table.saveFailed')}: ${saveError}`}>
3050-
{t('table.saveFailed')}: {saveError}
3051-
</span>
3052-
</div>
3053-
)}
30543122
<div className="text-sm text-muted-foreground">
30553123
{t('table.modified', { count: pendingChanges.size })}
30563124
</div>
@@ -3093,6 +3161,31 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
30933161
</div>
30943162
)}
30953163
</div>
3164+
3165+
{/* The last save's refusal, on a line of its own and said whole —
3166+
one line per reason (objectstack-ai/objectstack#27520). The
3167+
cells it names are marked in the table below. */}
3168+
{hasPendingChanges && saveError && (
3169+
<div
3170+
role="alert"
3171+
data-testid="table-save-refusal"
3172+
className="flex items-start gap-1.5 text-sm text-destructive sm:basis-full"
3173+
>
3174+
<AlertCircle className="h-4 w-4 flex-none mt-0.5" />
3175+
{saveError.length === 1 ? (
3176+
<span className="min-w-0 break-words">
3177+
{t('table.saveFailed')}: {saveError[0]}
3178+
</span>
3179+
) : (
3180+
<div className="min-w-0 break-words">
3181+
<span>{t('table.saveFailed')}:</span>
3182+
<ul className="list-disc pl-5">
3183+
{saveError.map((line) => <li key={line}>{line}</li>)}
3184+
</ul>
3185+
</div>
3186+
)}
3187+
</div>
3188+
)}
30963189
</div>
30973190
)}
30983191

@@ -3453,6 +3546,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
34533546
const rowHasChanges = rowPending !== undefined;
34543547
const rowChanges = rowPending?.changes || {};
34553548
const rowErrored = erroredRows.has(rowKey);
3549+
const rowCellErrors = cellErrors.get(rowKey);
34563550

34573551
return (
34583552
// objectstack#27545 — the row of the record a pane beside
@@ -3669,6 +3763,9 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
36693763
const hasPendingChange = rowChanges[col.accessorKey] !== undefined;
36703764
const cellValue = hasPendingChange ? rowChanges[col.accessorKey] : originalValue;
36713765
const isEditing = editingCell !== null && editingCell.rowKey === rowKey && editingCell.columnKey === col.accessorKey;
3766+
// The reason the last refusal gave for THIS cell, if it
3767+
// named it (objectstack-ai/objectstack#27520).
3768+
const cellError = rowCellErrors?.[col.accessorKey];
36723769
// A masked column reads as NOT editable here too
36733770
// (objectui#10583), so its cell neither shows the
36743771
// edit cursor nor swallows the row's click —
@@ -3700,6 +3797,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
37003797
isFit ? 'whitespace-nowrap' : 'overflow-hidden',
37013798
isEditable && !isEditing && "cursor-text hover:bg-muted/50",
37023799
hasPendingChange && "font-semibold text-amber-700 dark:text-amber-400",
3800+
cellError !== undefined && "ring-2 ring-inset ring-destructive",
37033801
isFrozen && ['sticky z-10 bg-background', STICKY_ROW_TINT],
37043802
isFrozen && colIndex === frozenColumns - 1 && 'border-r-2 border-border shadow-[2px_0_4px_-2px_rgba(0,0,0,0.1)]',
37053803
)}
@@ -3742,6 +3840,9 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
37423840
tabIndex={keyboardNavigation ? (rowIndex === rovingRowIndex && colIndex === rovingColIndex ? 0 : -1) : 0}
37433841
data-grid-cell={keyboardNavigation ? `${rowIndex}:${colIndex}` : undefined}
37443842
onFocus={keyboardNavigation ? () => rove(rowIndex, colIndex) : undefined}
3843+
aria-invalid={cellError !== undefined || undefined}
3844+
data-cell-error={cellError}
3845+
title={cellError}
37453846
>
37463847
{isEditing ? (
37473848
(() => {

‎ui/packages/components/src/renderers/form/form.tsx‎

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1828,8 +1828,10 @@ ComponentRegistry.register('form',
18281828
/**
18291829
* The sonner id every OUTCOME toast of THIS form is published under.
18301830
*
1831-
* The in-form banner (`submitError`) and its toast are raised together at
1832-
* each of the three points below, but only the banner was ever retired
1831+
* A refusal is no longer toasted at all (`raiseRefusal` below), but the id
1832+
* still retires whatever outcome toast an earlier attempt left. History:
1833+
* the in-form banner (`submitError`) and its toast were once raised together
1834+
* at each refusal point, but only the banner was ever retired
18331835
* together: the toast went out under sonner's auto-generated id, so nothing
18341836
* here held a handle on it and a refused submit's toast outlived the
18351837
* attempt that raised it. A later SUCCESSFUL submit — whose success toast a
@@ -1848,6 +1850,25 @@ ComponentRegistry.register('form',
18481850
*/
18491851
const formInstanceId = React.useId();
18501852
const outcomeToastId = `form-outcome:${formInstanceId}`;
1853+
/**
1854+
* A refused submit is said ONCE, by the banner above the fields
1855+
* (objectstack-ai/objectstack#27520). The toast that used to repeat it word
1856+
* for word was there for the banner a long modal or drawer form had
1857+
* scrolled out of view; instead the banner is brought into view each time
1858+
* it is raised, so no second copy of the refusal is needed. The stale
1859+
* toast of an earlier attempt is still dismissed under the outcome id.
1860+
*/
1861+
const submitBannerRef = React.useRef<HTMLDivElement>(null);
1862+
const [refusalRaised, setRefusalRaised] = React.useState(0);
1863+
const raiseRefusal = (message: string | string[]) => {
1864+
toast.dismiss(outcomeToastId);
1865+
setSubmitError(message);
1866+
setRefusalRaised((n) => n + 1);
1867+
};
1868+
React.useEffect(() => {
1869+
if (refusalRaised === 0) return;
1870+
submitBannerRef.current?.scrollIntoView?.({ block: 'nearest' });
1871+
}, [refusalRaised]);
18511872
// Active `fieldTabs` panel. Undefined until something picks one — the
18521873
// effective tab falls back to `defaultFieldTab`, then to the first tab.
18531874
// Which tab of an in-progress form is showing is presentational and dies
@@ -3051,10 +3072,7 @@ ComponentRegistry.register('form',
30513072

30523073
// Check if submission returned an error
30533074
if (result?.error) {
3054-
setSubmitError(result.error);
3055-
// Also surface as a toast so the message is visible even when the
3056-
// in-form banner has scrolled out of view (long forms in modals/drawers).
3057-
toast.error(result.error, { id: outcomeToastId });
3075+
raiseRefusal(result.error);
30583076
return;
30593077
}
30603078
}
@@ -3140,10 +3158,7 @@ ComponentRegistry.register('form',
31403158
if (isPermissionError(error) && typeof console !== 'undefined') {
31413159
console.warn('[form] write denied:', error);
31423160
}
3143-
setSubmitError(errorMessage);
3144-
// Also surface as a toast so the message is visible even when the
3145-
// in-form banner has scrolled out of view (long forms in modals/drawers).
3146-
toast.error(refusalContent(errorMessage), { id: outcomeToastId });
3161+
raiseRefusal(errorMessage);
31473162

31483163
// Log errors for debugging (dev environment only)
31493164
// process may not be defined in all environments
@@ -3209,14 +3224,12 @@ ComponentRegistry.register('form',
32093224
const handleSubmit = (event?: React.BaseSyntheticEvent) => {
32103225
if (faultedVisibleWhenFieldNames.length > 0) {
32113226
event?.preventDefault?.();
3212-
toast.dismiss(outcomeToastId);
32133227
const message = t('form.visibleWhenFaulted', {
32143228
fields: faultedVisibleWhenFieldNames
32153229
.map((n) => fieldLabelByName[n] || n)
32163230
.join(t('validation.formInvalidJoiner')),
32173231
});
3218-
setSubmitError(message);
3219-
toast.error(message, { id: outcomeToastId });
3232+
raiseRefusal(message);
32203233
// Marks the tabs that hold the fields, as a rejected submit does.
32213234
setRejectedFieldNames(faultedVisibleWhenFieldNames);
32223235
return Promise.resolve();
@@ -4076,7 +4089,7 @@ ComponentRegistry.register('form',
40764089
>
40774090
{/* Form Error Alert */}
40784091
{submitError && (
4079-
<Alert variant="destructive" className="mb-4">
4092+
<Alert ref={submitBannerRef} variant="destructive" className="mb-4">
40804093
<AlertCircle className="h-4 w-4" />
40814094
<AlertDescription>{refusalContent(submitError)}</AlertDescription>
40824095
</Alert>

0 commit comments

Comments
 (0)