@@ -11,7 +11,7 @@ import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
1111import { DetailView , RecordChatterPanel , buildDefaultPageSchema , extractMentions } from '@object-ui/plugin-detail' ;
1212import { Empty , EmptyTitle , EmptyDescription } from '@object-ui/components' ;
1313import { useAuth , createAuthenticatedFetch } from '@object-ui/auth' ;
14- import { ActionProvider , useObjectTranslation , useObjectLabel , usePageAssignment , RecordContextProvider , SchemaRenderer , DiscussionContextProvider , HighlightFieldsProvider } from '@object-ui/react' ;
14+ import { ActionProvider , useObjectTranslation , useObjectLabel , usePageAssignment , RecordContextProvider , SchemaRenderer , DiscussionContextProvider , HighlightFieldsProvider , useGlobalUndo } from '@object-ui/react' ;
1515import { buildExpandFields } from '@object-ui/core' ;
1616import { toast } from 'sonner' ;
1717import { useRecordPresence , PresenceAvatars } from '@object-ui/collaboration' ;
@@ -347,10 +347,24 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
347347 } ) ;
348348 } , [ objectName , objectDef , objects , fieldLabel , fieldOptionLabel , actionParamText , actionParamOptionLabel ] ) ;
349349
350- const toastHandler = useCallback ( ( message : string , options ?: { type ?: string } ) => {
351- if ( options ?. type === 'error' ) toast . error ( message ) ;
352- else toast . success ( message ) ;
353- } , [ ] ) ;
350+ // Global undo/redo (Ctrl+Z), backed by the dataSource — the success toast's
351+ // "Undo" button (for `undoable` actions) restores the record's prior values.
352+ const undoCtl = useGlobalUndo ( {
353+ dataSource,
354+ onUndo : ( ) => { setActionRefreshKey ( k => k + 1 ) ; toast . success ( 'Change undone' ) ; } ,
355+ } ) ;
356+
357+ const toastHandler = useCallback ( ( message : string , options ?: { type ?: string ; duration ?: number ; undo ?: { label ?: string } } ) => {
358+ if ( options ?. type === 'error' ) { toast . error ( message ) ; return ; }
359+ if ( options ?. undo ) {
360+ toast . success ( message , {
361+ duration : options . duration ,
362+ action : { label : options . undo . label || 'Undo' , onClick : ( ) => { void undoCtl . undo ( ) ; } } ,
363+ } ) ;
364+ return ;
365+ }
366+ toast . success ( message , { duration : options ?. duration } ) ;
367+ } , [ undoCtl ] ) ;
354368
355369 const navigateHandler = useCallback ( ( url : string , options ?: { external ?: boolean ; newTab ?: boolean } ) => {
356370 if ( options ?. external || options ?. newTab ) {
@@ -364,8 +378,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
364378 const apiHandler = useCallback ( async ( action : ActionDef ) => {
365379 try {
366380 const target = action . target || action . name ;
367- const params = action . params || { } ;
381+ const params : Record < string , any > = { ...( action . params || { } ) } ;
382+ delete params . _rowRecord ;
368383
384+ let undo : any ;
369385 switch ( target ) {
370386 case 'opportunity_change_stage' :
371387 await dataSource . update ( objectName ! , pureRecordId ! , { stage : params . new_stage } ) ;
@@ -379,6 +395,22 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
379395 default :
380396 // Generic: update record with collected params
381397 if ( Object . keys ( params ) . length > 0 ) {
398+ // Undoable single-record update: capture the changed fields' prior
399+ // values from the loaded record so the success toast can offer Undo.
400+ if ( action . undoable && pageRecord && objectName && pureRecordId ) {
401+ const undoData : Record < string , unknown > = { } ;
402+ for ( const k of Object . keys ( params ) ) undoData [ k ] = ( pageRecord as any ) [ k ] ?? null ;
403+ undo = {
404+ id : `undo-${ objectName } -${ pureRecordId } -${ Date . now ( ) } ` ,
405+ type : 'update' ,
406+ objectName,
407+ recordId : String ( pureRecordId ) ,
408+ timestamp : Date . now ( ) ,
409+ description : action . label || `Undo ${ objectName } ` ,
410+ undoData,
411+ redoData : { ...params } ,
412+ } ;
413+ }
382414 await dataSource . update ( objectName ! , pureRecordId ! , params ) ;
383415 }
384416 break ;
@@ -387,12 +419,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
387419 const shouldRefresh = action . refreshAfter === true ;
388420 if ( shouldRefresh ) {
389421 setActionRefreshKey ( k => k + 1 ) ;
422+ } else if ( undo ) {
423+ // Even when refreshAfter isn't set, reflect the change so the user sees
424+ // it (and the subsequent Undo) on the open record.
425+ setActionRefreshKey ( k => k + 1 ) ;
390426 }
391- return { success : true , reload : shouldRefresh } ;
427+ return { success : true , reload : shouldRefresh , undo } ;
392428 } catch ( error ) {
393429 return { success : false , error : ( error as Error ) . message } ;
394430 }
395- } , [ dataSource , objectName , pureRecordId ] ) ;
431+ } , [ dataSource , objectName , pureRecordId , pageRecord ] ) ;
396432
397433 // Client-side modal transport: `type:'modal'` actions open here (Dialog /
398434 // Sheet / Drawer by `placement`) and render arbitrary SchemaNode content.
0 commit comments