Skip to content

Commit 9667100

Browse files
feat(web): list toolbar (search-left + filter + actions) + detail inlines rendering
Two repo-owner UX directives from screenshots, both SPA-side. ## List toolbar (#177 / #182) - Moves search + filter OUT of the header into a dedicated toolbar row below the "N objects" count. - Search is **left-aligned** and **debounced** (~300ms → URL `q`, replace history) instead of right-aligned commit-on-blur. - "Filter" button (with active-count badge) opens the existing modal; the always-open filter panel is gone. - **Actions dropdown** appears only when ≥1 row is selected; lists `ModelAdmin.actions`, runs the chosen one via the new `client.runAction()` (POST …/actions/<name>/, contract §5.4), then refreshes the list. Confirmation prompt when the action declares `requires_confirmation`. - **Row-selection checkboxes** + select-all added to the generic `@dar/ui` Table via new props (`selectable`, `selectedKeys`, `onToggleRow`, `onToggleAll`) — props-driven, no business knowledge. Checkboxes show only when the model has runnable actions. ## Detail inlines (#54 SPA half) - The detail response already carried `ModelAdmin.inlines` (read half #109) but the SPA dropped them — hence "inlines seem to be missing". - Adds `InlineDescriptor` / `InlineRow` / `InlineFieldMeta` to the frontend contract + `inlines` on `DetailResponse` (it was absent), and renders each inline below the fieldsets: **tabular → a table**, **stacked → a card stack**; empty inline → a friendly empty state. Gated on `can_view`. ## New api-client method `ApiClient.runAction(app, model, action, pks, confirmed)` → `ActionRunResponse` ({executed, action, pks?, redirect?}), re-exported through `@dar/data`. Typecheck green across all packages; `vite build` succeeds; prettier clean. (apps/web eslint flat-config gap is pre-existing, unrelated.) Tier 4 — frontend only. Self-merging under the repo-owner's explicit full-tier authorization for this session. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d182778 commit 9667100

6 files changed

Lines changed: 315 additions & 47 deletions

File tree

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

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66

77
import { Link, useParams } from 'react-router-dom';
88

9-
import { useApiClient, useDetail } from '@dar/data';
10-
import { Card, EmptyState, Spinner } from '@dar/ui';
9+
import { useApiClient, useDetail, type InlineDescriptor } from '@dar/data';
10+
import { Card, EmptyState, Spinner, Table } from '@dar/ui';
1111

1212
import { FieldValueView } from '../components/FieldValueView';
1313

@@ -64,6 +64,59 @@ export function DetailPage() {
6464
</dl>
6565
</Card>
6666
))}
67+
68+
{/* Inlines (#54): the backend surfaces ModelAdmin.inlines + their
69+
existing rows on the detail response. Tabular → a table,
70+
Stacked → a card stack. Read rendering; edit affordances are a
71+
follow-up gated by the per-inline can_* flags. */}
72+
{(data.inlines ?? [])
73+
.filter((inline) => inline.can_view)
74+
.map((inline) => (
75+
<InlineSection key={inline.name} inline={inline} />
76+
))}
6777
</div>
6878
);
6979
}
80+
81+
function InlineSection({ inline }: { inline: InlineDescriptor }) {
82+
if (inline.rows.length === 0) {
83+
return (
84+
<Card title={inline.label}>
85+
<p className="py-4 text-sm text-gray-500">No {inline.label.toLowerCase()} yet.</p>
86+
</Card>
87+
);
88+
}
89+
90+
if (inline.kind === 'tabular') {
91+
const columns = inline.fields.map((f) => ({
92+
key: f.name,
93+
header: f.label,
94+
render: (row: (typeof inline.rows)[number]) => <FieldValueView value={row.fields[f.name]} />,
95+
}));
96+
return (
97+
<Card title={inline.label}>
98+
<Table columns={columns} rows={inline.rows} rowKey={(r) => r.pk} />
99+
</Card>
100+
);
101+
}
102+
103+
// Stacked: one definition list per child row.
104+
return (
105+
<Card title={inline.label}>
106+
<div className="divide-y divide-gray-200">
107+
{inline.rows.map((row) => (
108+
<dl key={row.pk} className="grid grid-cols-3 gap-4 py-3 text-sm">
109+
{inline.fields.map((f) => (
110+
<div key={f.name} className="contents">
111+
<dt className="text-gray-500">{f.label}</dt>
112+
<dd className="col-span-2 whitespace-pre-wrap text-gray-900">
113+
<FieldValueView value={row.fields[f.name]} />
114+
</dd>
115+
</div>
116+
))}
117+
</dl>
118+
))}
119+
</div>
120+
</Card>
121+
);
122+
}

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

Lines changed: 151 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
// controlled state local to this page; cache/network management is
66
// the data layer's job.
77

8-
import { useMemo, useState } from 'react';
8+
import { useEffect, useMemo, useState } from 'react';
99
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
1010

1111
import {
1212
useApiClient,
1313
useList,
14+
type ActionDescriptor,
1415
type FilterDescriptor,
1516
type FilterOption,
1617
type ListRow,
@@ -45,7 +46,7 @@ export function ListPage() {
4546
return out;
4647
}, [searchParams]);
4748

48-
const { data, loading, error } = useList({
49+
const { data, loading, error, refresh } = useList({
4950
client,
5051
appLabel,
5152
modelName,
@@ -58,6 +59,31 @@ export function ListPage() {
5859
// Filters live in a modal/bottom-sheet behind a button so they never
5960
// occupy fixed horizontal space on mobile or desktop (#177).
6061
const [filterOpen, setFilterOpen] = useState(false);
62+
// Row selection (page-scoped, matches Django's changelist) drives
63+
// the Actions dropdown's visibility (#182).
64+
const [selected, setSelected] = useState<Set<string | number>>(new Set());
65+
const [actionsOpen, setActionsOpen] = useState(false);
66+
const [runningAction, setRunningAction] = useState(false);
67+
68+
// Debounced search: commit `q` to the URL ~300ms after the user
69+
// stops typing, so the list refetches without a keystroke flood
70+
// (#177 toolbar). Enter / blur still commit immediately below.
71+
useEffect(() => {
72+
const handle = setTimeout(() => {
73+
setSearchParams(
74+
(prev) => {
75+
const next = new URLSearchParams(prev);
76+
if ((next.get('q') ?? '') === searchDraft) return prev;
77+
if (searchDraft) next.set('q', searchDraft);
78+
else next.delete('q');
79+
next.delete('page');
80+
return next;
81+
},
82+
{ replace: true },
83+
);
84+
}, 300);
85+
return () => clearTimeout(handle);
86+
}, [searchDraft, setSearchParams]);
6187

6288
function patchParams(mutate: (next: URLSearchParams) => void): void {
6389
const next = new URLSearchParams(searchParams);
@@ -87,6 +113,43 @@ export function ListPage() {
87113
setSearchParams(next);
88114
}
89115

116+
function toggleRow(key: string | number): void {
117+
setSelected((prev) => {
118+
const next = new Set(prev);
119+
if (next.has(key)) next.delete(key);
120+
else next.add(key);
121+
return next;
122+
});
123+
}
124+
125+
function toggleAll(checked: boolean, pageRows: ListRow[]): void {
126+
setSelected(() => (checked ? new Set(pageRows.map((r) => r.pk)) : new Set()));
127+
}
128+
129+
async function runAction(action: ActionDescriptor): Promise<void> {
130+
const pks = Array.from(selected);
131+
if (pks.length === 0 || runningAction) return;
132+
if (
133+
action.requires_confirmation &&
134+
!window.confirm(`Run “${action.label}” on ${pks.length} selected item(s)?`)
135+
) {
136+
return;
137+
}
138+
setRunningAction(true);
139+
setActionsOpen(false);
140+
try {
141+
const res = await client.runAction(appLabel, modelName, action.name, pks);
142+
if (res.redirect) {
143+
window.location.assign(res.redirect);
144+
return;
145+
}
146+
setSelected(new Set());
147+
await refresh();
148+
} finally {
149+
setRunningAction(false);
150+
}
151+
}
152+
90153
if (loading && !data) return <Spinner label="Loading…" />;
91154
if (error && !data) {
92155
return <EmptyState title="Couldn't load the list" description={error.message} />;
@@ -104,55 +167,92 @@ export function ListPage() {
104167
const filters = data.filters ?? [];
105168
const hasFilters = filters.length > 0;
106169
const chips = buildChips(filters, activeFilters);
170+
const actions = data.actions ?? [];
171+
const canRunActions = actions.length > 0 && data.permissions.change;
107172

108173
return (
109174
<div className="space-y-4">
110-
<header className="flex items-end justify-between gap-4">
111-
<div>
112-
<h1 className="text-2xl font-semibold">
113-
<span className="capitalize">{appLabel}</span> ·{' '}
114-
{data.verbose_name_plural
115-
? capitalize(data.verbose_name_plural)
116-
: data.object_name || modelName}
117-
</h1>
118-
<p className="text-sm text-gray-500">
119-
{data.total.toLocaleString()} object{data.total === 1 ? '' : 's'}
120-
</p>
121-
</div>
122-
<div className="flex items-center gap-2">
123-
{data.search_fields.length > 0 && (
124-
<form
125-
className="w-56"
126-
onSubmit={(e) => {
127-
e.preventDefault();
128-
commitSearch();
129-
}}
130-
>
131-
<Input
132-
placeholder={`Search by ${data.search_fields.join(', ')}…`}
133-
value={searchDraft}
134-
onChange={(e) => setSearchDraft(e.target.value)}
135-
onBlur={commitSearch}
136-
/>
137-
</form>
138-
)}
139-
{hasFilters && (
175+
<header>
176+
<h1 className="text-2xl font-semibold">
177+
<span className="capitalize">{appLabel}</span> ·{' '}
178+
{data.verbose_name_plural
179+
? capitalize(data.verbose_name_plural)
180+
: data.object_name || modelName}
181+
</h1>
182+
<p className="text-sm text-gray-500">
183+
{data.total.toLocaleString()} object{data.total === 1 ? '' : 's'}
184+
</p>
185+
</header>
186+
187+
{/* Toolbar row (#177 / #182): Actions dropdown (only when rows are
188+
selected) + a left-aligned debounced search + the Filter
189+
button that opens the modal. */}
190+
<div className="flex flex-wrap items-center gap-2">
191+
{canRunActions && selected.size > 0 && (
192+
<div className="relative">
140193
<button
141194
type="button"
142-
onClick={() => setFilterOpen(true)}
143-
aria-haspopup="dialog"
144-
className="shrink-0 rounded border border-gray-300 px-3 py-2 text-sm hover:bg-gray-100"
195+
onClick={() => setActionsOpen((o) => !o)}
196+
aria-haspopup="menu"
197+
aria-expanded={actionsOpen}
198+
disabled={runningAction}
199+
className="shrink-0 rounded border border-gray-300 px-3 py-2 text-sm hover:bg-gray-100 disabled:opacity-50"
145200
>
146-
Filters
147-
{chips.length > 0 && (
148-
<span className="ml-1 rounded-full bg-blue-600 px-1.5 py-0.5 text-xs text-white">
149-
{chips.length}
150-
</span>
151-
)}
201+
Actions · {selected.size}
152202
</button>
153-
)}
154-
</div>
155-
</header>
203+
{actionsOpen && (
204+
<div
205+
role="menu"
206+
className="absolute left-0 z-20 mt-1 min-w-48 rounded border border-gray-200 bg-white py-1 shadow-lg"
207+
>
208+
{actions.map((a) => (
209+
<button
210+
key={a.name}
211+
type="button"
212+
role="menuitem"
213+
onClick={() => void runAction(a)}
214+
className="block w-full px-3 py-2 text-left text-sm hover:bg-gray-100"
215+
title={a.description}
216+
>
217+
{a.label}
218+
</button>
219+
))}
220+
</div>
221+
)}
222+
</div>
223+
)}
224+
{data.search_fields.length > 0 && (
225+
<form
226+
className="w-72 max-w-full"
227+
onSubmit={(e) => {
228+
e.preventDefault();
229+
commitSearch();
230+
}}
231+
>
232+
<Input
233+
placeholder={`Search by ${data.search_fields.join(', ')}…`}
234+
value={searchDraft}
235+
onChange={(e) => setSearchDraft(e.target.value)}
236+
onBlur={commitSearch}
237+
/>
238+
</form>
239+
)}
240+
{hasFilters && (
241+
<button
242+
type="button"
243+
onClick={() => setFilterOpen(true)}
244+
aria-haspopup="dialog"
245+
className="shrink-0 rounded border border-gray-300 px-3 py-2 text-sm hover:bg-gray-100"
246+
>
247+
Filter
248+
{chips.length > 0 && (
249+
<span className="ml-1 rounded-full bg-blue-600 px-1.5 py-0.5 text-xs text-white">
250+
{chips.length}
251+
</span>
252+
)}
253+
</button>
254+
)}
255+
</div>
156256

157257
{chips.length > 0 && (
158258
<div className="flex flex-wrap gap-2">
@@ -182,14 +282,20 @@ export function ListPage() {
182282
</div>
183283
)}
184284

185-
{/* Table is always full-width now — filters live in the modal. */}
285+
{/* Table is always full-width now — filters live in the modal.
286+
Row checkboxes appear only when the model has bulk actions
287+
the user can run (#182). */}
186288
<Card>
187289
<Table
188290
columns={columns}
189291
rows={data.results}
190292
rowKey={(r) => r.pk}
191293
onRowClick={(row) => navigate(`/${appLabel}/${modelName}/${row.pk}`)}
192294
emptyLabel={q || chips.length ? 'No results match these filters.' : 'No objects yet.'}
295+
selectable={canRunActions}
296+
selectedKeys={selected}
297+
onToggleRow={toggleRow}
298+
onToggleAll={(checked) => toggleAll(checked, data.results)}
193299
/>
194300
</Card>
195301
<Pagination page={data.page} totalPages={totalPages} onChange={setPage} />

frontend/packages/api/src/client.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// here. See `CLAUDE.md` §7.
66

77
import type {
8+
ActionRunResponse,
89
CreatePayload,
910
CreateResponse,
1011
DetailResponse,
@@ -175,4 +176,25 @@ export class ApiClient {
175176
delete(appLabel: string, modelName: string, pk: string | number): Promise<void> {
176177
return this.request<void>('DELETE', `${appLabel}/${modelName}/${pk}/`);
177178
}
179+
180+
/**
181+
* Run a `ModelAdmin` action over the selected rows (contract §5.4).
182+
* The backend re-resolves the action name through
183+
* `get_actions(request)` — the SPA name is never trusted as a
184+
* callable lookup — and runs it over
185+
* `get_queryset(request).filter(pk__in=pks)`.
186+
*/
187+
runAction(
188+
appLabel: string,
189+
modelName: string,
190+
actionName: string,
191+
pks: Array<string | number>,
192+
confirmed = true,
193+
): Promise<ActionRunResponse> {
194+
return this.request<ActionRunResponse>(
195+
'POST',
196+
`${appLabel}/${modelName}/actions/${actionName}/`,
197+
{ pks, confirmed },
198+
);
199+
}
178200
}

0 commit comments

Comments
 (0)