@@ -18,7 +18,7 @@ import { describeIgnoredBind, describeNonArrayData } from './dataTableBindDiagno
1818import { ComponentRegistry , compareSortValues , evalRowPredicate , formatDate , hasDeclaredPredicate , isActionSwitchedOff , formatDateTime , fromDateTimeInputValue , getSortValue , isEmptyValue , isImpossibleStoredDay , toDateInputValue , toDateTimeInputValue } from '@object-ui/core' ;
1919import type { DataTableSchema , TableColumn , TableSortItem , TableColumnType } from '@object-ui/types' ;
2020import 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' ;
2222import { createSafeTranslation , useDisplayLocale } from '@object-ui/i18n' ;
2323import {
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 ( ( ) => {
0 commit comments