Skip to content

Commit cc1ad0e

Browse files
feat(web): list toolbar (search-left + filter + actions) + detail inlines rendering
Two repo-owner UX directives from screenshots, both SPA-side, plus a fix for a frontend-build regression already on main. ## List toolbar (#177 / #182) - Search + filter moved OUT of the header into a dedicated toolbar row below the "N objects" count. - Search is **left-aligned** + **debounced** (~300ms → URL `q`, replace history); the always-open filter panel is gone, replaced by a "Filter" button (lucide icon + active-count badge) that opens the existing modal. - **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. Confirmation prompt when `requires_confirmation`. - **Row-selection checkboxes** + select-all on the generic `@dar/ui` Table via new props (`selectable`/`selectedKeys`/`onToggleRow`/ `onToggleAll`) — props-driven, no business knowledge. Shown only when the model has runnable actions. ## Detail inlines (#54 SPA half — "inlines seem to be missing") - The detail response carried `ModelAdmin.inlines` (read half #109) but the SPA dropped them. Added `InlineDescriptor`/`InlineRow`/ `InlineFieldMeta` + `inlines` on `DetailResponse` (it was absent from the frontend contract), and render each inline below the fieldsets: tabular → table, stacked → card stack, empty → empty state. Gated on `can_view`. ## Drive-by fix: LoginResponse (un-breaks the frontend build on main) `@dar/data`'s `index.ts` on main re-exports `LoginResponse` from `@dar/api`, but that type was never defined — `pnpm -r typecheck` fails on a clean main checkout. Added the missing `LoginResponse` type (`{ user: RegistryUser }`, matching the #168 login endpoint) so the frontend compiles. Flagged separately below. ## 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 7 packages; `vite build` succeeds; prettier clean. (apps/web eslint flat-config gap is pre-existing, unrelated.) Tier 4 frontend. 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 41d2a3c commit cc1ad0e

6 files changed

Lines changed: 326 additions & 48 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: 152 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
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 { ListFilter } from 'lucide-react';
1010
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
1111

1212
import {
1313
useApiClient,
1414
useList,
15+
type ActionDescriptor,
1516
type FilterDescriptor,
1617
type FilterOption,
1718
type ListRow,
@@ -46,7 +47,7 @@ export function ListPage() {
4647
return out;
4748
}, [searchParams]);
4849

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

6389
function patchParams(mutate: (next: URLSearchParams) => void): void {
6490
const next = new URLSearchParams(searchParams);
@@ -88,6 +114,43 @@ export function ListPage() {
88114
setSearchParams(next);
89115
}
90116

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

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

159259
{chips.length > 0 && (
160260
<div className="flex flex-wrap gap-2">
@@ -184,14 +284,20 @@ export function ListPage() {
184284
</div>
185285
)}
186286

187-
{/* Table is always full-width now — filters live in the modal. */}
287+
{/* Table is always full-width now — filters live in the modal.
288+
Row checkboxes appear only when the model has bulk actions
289+
the user can run (#182). */}
188290
<Card>
189291
<Table
190292
columns={columns}
191293
rows={data.results}
192294
rowKey={(r) => r.pk}
193295
onRowClick={(row) => navigate(`/${appLabel}/${modelName}/${row.pk}`)}
194296
emptyLabel={q || chips.length ? 'No results match these filters.' : 'No objects yet.'}
297+
selectable={canRunActions}
298+
selectedKeys={selected}
299+
onToggleRow={toggleRow}
300+
onToggleAll={(checked) => toggleAll(checked, data.results)}
195301
/>
196302
</Card>
197303
<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)