@@ -22,8 +22,6 @@ import {
2222 updateObject ,
2323 useApiClient ,
2424 useDetail ,
25- useList ,
26- type ActionDescriptor ,
2725 type CustomView ,
2826 type DeletePreviewResponse ,
2927 type DetailResponse ,
@@ -186,18 +184,13 @@ export function DetailPage() {
186184 const [ historyOpen , setHistoryOpen ] = useState ( false ) ;
187185 const { plural : modelPlural } = useModelMeta ( appLabel , modelName ) ;
188186
189- // Changelist actions on the detail page (#555). `ModelAdmin.actions` is
190- // surfaced in the list response, not the detail response, so we read it
191- // through `useList` with `pageSize: 1` — cheap and idempotent (the data
192- // layer caches it; arrives essentially free for users who came from the
193- // list). The action runner is the **same** endpoint the changelist uses,
194- // called with a one-pk array; the SPA pulls in `requires_confirmation`,
195- // `message_user` toasts, and the intermediate-redirect-in-new-tab flow
196- // unchanged.
197- const listMeta = useList ( { client, appLabel, modelName, page : 1 , pageSize : 1 } ) ;
198- const detailActions : ActionDescriptor [ ] = listMeta . data ?. actions ?? [ ] ;
199- const [ pendingAction , setPendingAction ] = useState < ActionDescriptor | null > ( null ) ;
200- const [ runningAction , setRunningAction ] = useState ( false ) ;
187+ // Detail-page action buttons (#571): per-object actions only, via
188+ // `django-object-actions` (`change_actions`). Surfaced by the API as
189+ // `data.object_actions` and rendered below via <ObjectActionButton>.
190+ // The original #555 attempt — running `ModelAdmin.actions` (changelist
191+ // bulk actions) on a one-pk slice — was the wrong primitive: bulk
192+ // actions are *list* semantics and confused the operator on a
193+ // single-object page. Reverted in v1.0.2.
201194
202195 if ( loading && ! data ) return < RecordSkeleton /> ;
203196 if ( error && ! data ) {
@@ -212,57 +205,16 @@ export function DetailPage() {
212205 // changelist filters (#441) when they arrived from a filtered list.
213206 const listPath = listPathWithPreservedFilters ( `/${ appLabel } /${ modelName } ` , searchParams ) ;
214207
215- // Detail-page action runner (#555). Mirrors the changelist's runner
216- // exactly — `requires_confirmation` opens the styled confirm modal;
217- // otherwise runs immediately. The wire payload is a one-pk array
218- // (`[pk]`), so the server-side permission gate + queryset filter
219- // operates over a single row identically to the changelist flow.
220- function requestDetailAction ( action : ActionDescriptor ) : void {
221- if ( runningAction ) return ;
222- if ( action . requires_confirmation ) {
223- setPendingAction ( action ) ;
224- } else {
225- void performDetailAction ( action ) ;
226- }
227- }
228-
229- async function performDetailAction ( action : ActionDescriptor ) : Promise < void > {
230- if ( runningAction ) return ;
231- setRunningAction ( true ) ;
232- setPendingAction ( null ) ;
233- try {
234- const result = await client . runAction ( appLabel , modelName , action . name , [ pk ] ) ;
235- // Intermediate / form-returning action (#250): the server forwards
236- // the action's Location as `redirect`; open it in a new tab so the
237- // operator can complete the flow there — the SPA stays mounted.
238- if ( result . redirect ) {
239- window . open ( result . redirect , '_blank' , 'noopener,noreferrer' ) ;
240- toast . info ( `${ action . label } opened in a new tab.` ) ;
241- return ;
242- }
243- await refresh ( ) ;
244- // Prefer the action's own `message_user` output (#442).
245- const msgs = result . messages ?? [ ] ;
246- if ( msgs . length > 0 ) {
247- for ( const m of msgs ) {
248- if ( m . level === 'error' || m . level === 'warning' ) toast . error ( m . message ) ;
249- else if ( m . level === 'info' || m . level === 'debug' ) toast . info ( m . message ) ;
250- else toast . success ( m . message ) ;
251- }
252- } else {
253- toast . success ( `${ action . label } — “${ data ?. label ?? '' } ”.` ) ;
254- }
255- } catch ( e ) {
256- toast . error ( e instanceof Error ? e . message : 'Action failed.' ) ;
257- } finally {
258- setRunningAction ( false ) ;
259- }
260- }
261-
262208 return (
263209 < div className = "space-y-4" >
210+ { /* Header (#572): the title is the page's most important element
211+ and gets as much horizontal space as it needs (`flex-1
212+ min-w-0`); the toolbar is `shrink-0` and only pushes the title
213+ when it genuinely can't fit on its row. `justify-end` on the
214+ toolbar's flex-wrap keeps wrapped button rows flush right to
215+ the page padding, instead of left-aligned within their column. */ }
264216 < header className = "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4" >
265- < div className = "space-y-1" >
217+ < div className = "min-w-0 flex-1 space-y-1" >
266218 < Breadcrumb
267219 items = { [
268220 { label : 'Home' , to : '/' } ,
@@ -278,7 +230,7 @@ export function DetailPage() {
278230 < h1 className = "text-2xl font-semibold" > { data . label } </ h1 >
279231 </ div >
280232 { ! editing && (
281- < div className = "flex flex-wrap gap-2" >
233+ < div className = "flex shrink-0 flex-wrap justify-end gap-2" >
282234 < button
283235 type = "button"
284236 onClick = { ( ) => setHistoryOpen ( true ) }
@@ -323,23 +275,6 @@ export function DetailPage() {
323275 onError = { ( message ) => toast . error ( message ) }
324276 />
325277 ) ) }
326- { /* Changelist actions on the detail page (#555) — render only
327- when the user has change permission, mirroring the bulk
328- runner's visibility on the list page. Each button fires
329- the same `runAction` endpoint with a one-pk array. */ }
330- { canChange &&
331- detailActions . map ( ( action ) => (
332- < button
333- key = { action . name }
334- type = "button"
335- onClick = { ( ) => requestDetailAction ( action ) }
336- disabled = { runningAction }
337- title = { action . description }
338- className = "inline-flex items-center gap-1.5 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-50"
339- >
340- { action . label }
341- </ button >
342- ) ) }
343278 { canChange && (
344279 < Button variant = "primary" onClick = { ( ) => setEditing ( true ) } >
345280 Edit
@@ -437,31 +372,6 @@ export function DetailPage() {
437372 onClose = { ( ) => setHistoryOpen ( false ) }
438373 />
439374 ) }
440-
441- { /* Detail-page action confirm modal (#555) — same styled
442- confirmation as the changelist (#206), reads exactly "Run X on
443- this object?" to make the single-pk scope explicit. */ }
444- { pendingAction && (
445- < Modal
446- title = "Confirm action"
447- onClose = { ( ) => setPendingAction ( null ) }
448- footer = {
449- < >
450- < Button variant = "secondary" onClick = { ( ) => setPendingAction ( null ) } >
451- Cancel
452- </ Button >
453- < Button variant = "primary" onClick = { ( ) => void performDetailAction ( pendingAction ) } >
454- Run
455- </ Button >
456- </ >
457- }
458- >
459- < p className = "text-sm text-gray-700" >
460- Run < span className = "font-medium" > { pendingAction . label } </ span > on{ ' ' }
461- < span className = "font-medium" > “{ data . label } ”</ span > ?
462- </ p >
463- </ Modal >
464- ) }
465375 </ div >
466376 ) ;
467377}
0 commit comments