Skip to content

Commit 7b1265e

Browse files
feat(spa): in-product refresh — shared <RefreshButton> primitive on list + detail (#592)
The list page and the detail page had no in-product way to refetch the current view's data. Users who wanted to see another tab's change, a finished background job, or a status advance had to hit the browser reload — which re-fetches the SPA shell, blows away component state, and is markedly slower than an in-app refetch. Both useList() and useDetail() already return a `refresh: () => Promise<void>` from @dar/data's swr-cache; this PR wires that closure to a small icon button visible in both surfaces. What changes ------------ - New `frontend/packages/ui/src/RefreshButton.tsx` — generic icon-only button that calls `onRefresh` and shows a pending state (animate-spin on the passed icon node) while the promise resolves. Icon-agnostic, matching the @dar/ui contract (the caller passes the icon as a ReactNode; @dar/ui stays lucide-free). - `frontend/apps/web/src/pages/ListPage.tsx` — adds the button to the filter trailing slot, between Reset/Clear-all and Customize. Wired to `useList().refresh` so filters / search / ordering / pagination are preserved across the refetch. - `frontend/apps/web/src/pages/DetailPage.tsx` — adds the button to the read-mode header toolbar, between the per-object actions cluster and the Edit / Delete pair so destructive buttons stay at the trailing edge. Wired to `useDetail().refresh` — refetches the object, inlines, and any cached recent-actions panel. - `frontend/packages/ui/src/index.ts` exports `RefreshButton` + `RefreshButtonProps`. UX contract ----------- - Single click → in-flight refetch; button disabled with `cursor-wait` while pending. - Icon rotates (`animate-spin`) while pending; on resolve/reject the spinner stops and the button is interactive again. - No success toast — silent success is correct for a user-initiated refresh (the table / detail body re-render is the only feedback the user needs). - Tooltip and `aria-label` both default to "Refresh"; configurable per call site if needed. - Keyboard-focusable; activates on Enter / Space (native button). Bundle impact ------------- - Main JS: 265.02 → 266.14 kB (+1.1 kB raw / +0.3 kB gz) for the primitive + two call-site icon imports. - No new runtime dependency. RefreshCw is already in the lucide bundle via existing imports. No API contract change, no schema change, no settings change. Patch bump (1.3.1 → 1.3.2). Closes #592. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 158a9c1 commit 7b1265e

5 files changed

Lines changed: 102 additions & 4 deletions

File tree

frontend/apps/web/src/pages/DetailPage.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
// Edit/Delete are gated by the `permissions` block the API returns.
1111

1212
import { useCallback, useEffect, useRef, useState } from 'react';
13-
import { ChevronDown, Clock, ExternalLink } from 'lucide-react';
13+
import { ChevronDown, Clock, ExternalLink, RefreshCw } from 'lucide-react';
1414
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
1515

1616
import {
@@ -34,7 +34,7 @@ import {
3434
type WriteValue,
3535
} from '@dar/data';
3636
import { detailCollapseKey, usePersistedState } from '@dar/customization';
37-
import { Breadcrumb, Button, Card, EmptyState, Modal, Table } from '@dar/ui';
37+
import { Breadcrumb, Button, Card, EmptyState, Modal, RefreshButton, Table } from '@dar/ui';
3838
import { FieldValueView } from '@dar/details';
3939
import { FieldInput, InlineEditor } from '@dar/form';
4040
import { HistoryModal } from '@dar/history';
@@ -275,6 +275,15 @@ export function DetailPage() {
275275
onError={(message) => toast.error(message)}
276276
/>
277277
))}
278+
{/* Refresh (#592): refetch the object + inlines + history
279+
with no full page reload. Placed between the actions
280+
cluster and the Edit / Delete pair so destructive
281+
buttons stay at the trailing edge. */}
282+
<RefreshButton
283+
onRefresh={refresh}
284+
tooltip="Refresh"
285+
icon={<RefreshCw className="h-4 w-4" aria-hidden />}
286+
/>
278287
{canChange && (
279288
<Button variant="primary" onClick={() => setEditing(true)}>
280289
Edit

frontend/apps/web/src/pages/ListPage.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
// the data layer's job.
77

88
import { Suspense, lazy, useEffect, useMemo, useRef, useState } from 'react';
9-
import { Settings2, X } from 'lucide-react';
9+
import { RefreshCw, Settings2, X } from 'lucide-react';
1010
import { Link, useHref, useNavigate, useParams, useSearchParams } from 'react-router-dom';
1111

1212
import { useApiClient, useList, type ActionDescriptor, type ListRow } from '@dar/data';
@@ -29,6 +29,7 @@ import {
2929
Pagination,
3030
Popover,
3131
RecordCardList,
32+
RefreshButton,
3233
ResetButton,
3334
Table,
3435
useMediaQuery,
@@ -597,6 +598,15 @@ export function ListPage() {
597598
icon={<X className="h-4 w-4" aria-hidden />}
598599
title="Clear all filters"
599600
/>
601+
{/* Refresh (#592): refetch the changelist + filter counts
602+
with the current filter / search / ordering / page
603+
state preserved. Between Reset and Customize on the
604+
filter row. */}
605+
<RefreshButton
606+
onRefresh={refresh}
607+
tooltip="Refresh"
608+
icon={<RefreshCw className="h-4 w-4" aria-hidden />}
609+
/>
600610
<button
601611
type="button"
602612
onClick={() => setColsOpen(true)}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// RefreshButton (#592) — shared "refetch this page's data" affordance
2+
// for the list-page filter toolbar (between Reset and Customize) and
3+
// the detail-page header (next to History / Edit / Delete).
4+
//
5+
// Same code-organization pattern as ResetButton (#590): generic icon-
6+
// agnostic primitive in @dar/ui, callers pass the refetch closure
7+
// they already hold (`useList().refresh`, `useDetail().refresh`, …).
8+
// Both surfaces get the same visual contract — same border, same
9+
// pending spin, same a11y behaviour — by construction.
10+
11+
import { cloneElement, isValidElement, useState, type ReactNode } from 'react';
12+
13+
export interface RefreshButtonProps {
14+
/** Refetch closure. The button shows a pending state while the
15+
* promise is in flight; on resolve / reject the spinner stops. */
16+
onRefresh: () => Promise<void> | void;
17+
/** Visible tooltip (and aria-label, since the button is icon-only).
18+
* Default "Refresh". */
19+
tooltip?: string;
20+
/** Leading icon. If it accepts `className` the button applies
21+
* `animate-spin` while pending — the lucide convention. Pass
22+
* `null` to opt out. */
23+
icon?: ReactNode;
24+
/** Extra classes — kept narrow so the visual contract stays uniform. */
25+
className?: string;
26+
}
27+
28+
export function RefreshButton({
29+
onRefresh,
30+
tooltip = 'Refresh',
31+
icon = null,
32+
className,
33+
}: RefreshButtonProps) {
34+
const [pending, setPending] = useState(false);
35+
36+
async function run(): Promise<void> {
37+
if (pending) return;
38+
setPending(true);
39+
try {
40+
await onRefresh();
41+
} finally {
42+
setPending(false);
43+
}
44+
}
45+
46+
// If the caller's icon is a valid element that accepts a `className`,
47+
// augment it with `animate-spin` during a pending promise.
48+
const renderedIcon =
49+
pending && isValidElement<{ className?: string }>(icon)
50+
? cloneElement(icon, {
51+
className: `${icon.props.className ?? ''} animate-spin`.trim(),
52+
})
53+
: icon;
54+
55+
return (
56+
<button
57+
type="button"
58+
onClick={() => {
59+
void run();
60+
}}
61+
disabled={pending}
62+
title={tooltip}
63+
aria-label={tooltip}
64+
className={[
65+
'inline-flex shrink-0 items-center justify-center rounded-md border border-gray-300 px-2 py-1.5 text-sm',
66+
pending
67+
? 'cursor-wait text-gray-400'
68+
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900',
69+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500',
70+
className ?? '',
71+
].join(' ')}
72+
>
73+
{renderedIcon}
74+
</button>
75+
);
76+
}

frontend/packages/ui/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,6 @@ export type { PopoverProps } from './Popover';
4848

4949
export { ResetButton } from './ResetButton';
5050
export type { ResetButtonProps } from './ResetButton';
51+
52+
export { RefreshButton } from './RefreshButton';
53+
export type { RefreshButtonProps } from './RefreshButton';

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-admin-react"
3-
version = "1.3.1"
3+
version = "1.3.2"
44
description = "A drop-in React single-page admin for Django, driven entirely by ModelAdmin."
55
authors = ["django-admin-react contributors"]
66
license = "MIT"

0 commit comments

Comments
 (0)