Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions frontend/apps/web/src/pages/DetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

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

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

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

Expand Down Expand Up @@ -64,6 +64,59 @@ export function DetailPage() {
</dl>
</Card>
))}

{/* Inlines (#54): the backend surfaces ModelAdmin.inlines + their
existing rows on the detail response. Tabular → a table,
Stacked → a card stack. Read rendering; edit affordances are a
follow-up gated by the per-inline can_* flags. */}
{(data.inlines ?? [])
.filter((inline) => inline.can_view)
.map((inline) => (
<InlineSection key={inline.name} inline={inline} />
))}
</div>
);
}

function InlineSection({ inline }: { inline: InlineDescriptor }) {
if (inline.rows.length === 0) {
return (
<Card title={inline.label}>
<p className="py-4 text-sm text-gray-500">No {inline.label.toLowerCase()} yet.</p>
</Card>
);
}

if (inline.kind === 'tabular') {
const columns = inline.fields.map((f) => ({
key: f.name,
header: f.label,
render: (row: (typeof inline.rows)[number]) => <FieldValueView value={row.fields[f.name]} />,
}));
return (
<Card title={inline.label}>
<Table columns={columns} rows={inline.rows} rowKey={(r) => r.pk} />
</Card>
);
}

// Stacked: one definition list per child row.
return (
<Card title={inline.label}>
<div className="divide-y divide-gray-200">
{inline.rows.map((row) => (
<dl key={row.pk} className="grid grid-cols-3 gap-4 py-3 text-sm">
{inline.fields.map((f) => (
<div key={f.name} className="contents">
<dt className="text-gray-500">{f.label}</dt>
<dd className="col-span-2 whitespace-pre-wrap text-gray-900">
<FieldValueView value={row.fields[f.name]} />
</dd>
</div>
))}
</dl>
))}
</div>
</Card>
);
}
198 changes: 152 additions & 46 deletions frontend/apps/web/src/pages/ListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
// controlled state local to this page; cache/network management is
// the data layer's job.

import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { ListFilter } from 'lucide-react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';

import {
useApiClient,
useList,
type ActionDescriptor,
type FilterDescriptor,
type FilterOption,
type ListRow,
Expand Down Expand Up @@ -46,7 +47,7 @@ export function ListPage() {
return out;
}, [searchParams]);

const { data, loading, error } = useList({
const { data, loading, error, refresh } = useList({
client,
appLabel,
modelName,
Expand All @@ -59,6 +60,31 @@ export function ListPage() {
// Filters live in a modal/bottom-sheet behind a button so they never
// occupy fixed horizontal space on mobile or desktop (#177).
const [filterOpen, setFilterOpen] = useState(false);
// Row selection (page-scoped, matches Django's changelist) drives
// the Actions dropdown's visibility (#182).
const [selected, setSelected] = useState<Set<string | number>>(new Set());
const [actionsOpen, setActionsOpen] = useState(false);
const [runningAction, setRunningAction] = useState(false);

// Debounced search: commit `q` to the URL ~300ms after the user
// stops typing, so the list refetches without a keystroke flood
// (#177 toolbar). Enter / blur still commit immediately below.
useEffect(() => {
const handle = setTimeout(() => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if ((next.get('q') ?? '') === searchDraft) return prev;
if (searchDraft) next.set('q', searchDraft);
else next.delete('q');
next.delete('page');
return next;
},
{ replace: true },
);
}, 300);
return () => clearTimeout(handle);
}, [searchDraft, setSearchParams]);

function patchParams(mutate: (next: URLSearchParams) => void): void {
const next = new URLSearchParams(searchParams);
Expand Down Expand Up @@ -88,6 +114,43 @@ export function ListPage() {
setSearchParams(next);
}

function toggleRow(key: string | number): void {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}

function toggleAll(checked: boolean, pageRows: ListRow[]): void {
setSelected(() => (checked ? new Set(pageRows.map((r) => r.pk)) : new Set()));
}

async function runAction(action: ActionDescriptor): Promise<void> {
const pks = Array.from(selected);
if (pks.length === 0 || runningAction) return;
if (
action.requires_confirmation &&
!window.confirm(`Run “${action.label}” on ${pks.length} selected item(s)?`)
) {
return;
}
setRunningAction(true);
setActionsOpen(false);
try {
const res = await client.runAction(appLabel, modelName, action.name, pks);
if (res.redirect) {
window.location.assign(res.redirect);
return;
}
setSelected(new Set());
await refresh();
} finally {
setRunningAction(false);
}
}

if (loading && !data) return <Spinner label="Loading…" />;
if (error && !data) {
return <EmptyState title="Couldn't load the list" description={error.message} />;
Expand All @@ -105,56 +168,93 @@ export function ListPage() {
const filters = data.filters ?? [];
const hasFilters = filters.length > 0;
const chips = buildChips(filters, activeFilters);
const actions = data.actions ?? [];
const canRunActions = actions.length > 0 && data.permissions.change;

return (
<div className="space-y-4">
<header className="flex items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">
<span className="capitalize">{appLabel}</span> ·{' '}
{data.verbose_name_plural
? capitalize(data.verbose_name_plural)
: data.object_name || modelName}
</h1>
<p className="text-sm text-gray-500">
{data.total.toLocaleString()} object{data.total === 1 ? '' : 's'}
</p>
</div>
<div className="flex items-center gap-2">
{data.search_fields.length > 0 && (
<form
className="w-56"
onSubmit={(e) => {
e.preventDefault();
commitSearch();
}}
>
<Input
placeholder={`Search by ${data.search_fields.join(', ')}…`}
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onBlur={commitSearch}
/>
</form>
)}
{hasFilters && (
<header>
<h1 className="text-2xl font-semibold">
<span className="capitalize">{appLabel}</span> ·{' '}
{data.verbose_name_plural
? capitalize(data.verbose_name_plural)
: data.object_name || modelName}
</h1>
<p className="text-sm text-gray-500">
{data.total.toLocaleString()} object{data.total === 1 ? '' : 's'}
</p>
</header>

{/* Toolbar row (#177 / #182): Actions dropdown (only when rows are
selected) + a left-aligned debounced search + the Filter
button that opens the modal. */}
<div className="flex flex-wrap items-center gap-2">
{canRunActions && selected.size > 0 && (
<div className="relative">
<button
type="button"
onClick={() => setFilterOpen(true)}
aria-haspopup="dialog"
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"
onClick={() => setActionsOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={actionsOpen}
disabled={runningAction}
className="shrink-0 rounded border border-gray-300 px-3 py-2 text-sm hover:bg-gray-100 disabled:opacity-50"
>
<ListFilter className="h-4 w-4" aria-hidden />
Filters
{chips.length > 0 && (
<span className="ml-0.5 rounded-full bg-blue-600 px-1.5 py-0.5 text-xs text-white">
{chips.length}
</span>
)}
Actions · {selected.size} ▾
</button>
)}
</div>
</header>
{actionsOpen && (
<div
role="menu"
className="absolute left-0 z-20 mt-1 min-w-48 rounded border border-gray-200 bg-white py-1 shadow-lg"
>
{actions.map((a) => (
<button
key={a.name}
type="button"
role="menuitem"
onClick={() => void runAction(a)}
className="block w-full px-3 py-2 text-left text-sm hover:bg-gray-100"
title={a.description}
>
{a.label}
</button>
))}
</div>
)}
</div>
)}
{data.search_fields.length > 0 && (
<form
className="w-72 max-w-full"
onSubmit={(e) => {
e.preventDefault();
commitSearch();
}}
>
<Input
placeholder={`Search by ${data.search_fields.join(', ')}…`}
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onBlur={commitSearch}
/>
</form>
)}
{hasFilters && (
<button
type="button"
onClick={() => setFilterOpen(true)}
aria-haspopup="dialog"
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"
>
<ListFilter className="h-4 w-4" aria-hidden />
Filter
{chips.length > 0 && (
<span className="ml-0.5 rounded-full bg-blue-600 px-1.5 py-0.5 text-xs text-white">
{chips.length}
</span>
)}
</button>
)}
</div>

{chips.length > 0 && (
<div className="flex flex-wrap gap-2">
Expand Down Expand Up @@ -184,14 +284,20 @@ export function ListPage() {
</div>
)}

{/* Table is always full-width now — filters live in the modal. */}
{/* Table is always full-width now — filters live in the modal.
Row checkboxes appear only when the model has bulk actions
the user can run (#182). */}
<Card>
<Table
columns={columns}
rows={data.results}
rowKey={(r) => r.pk}
onRowClick={(row) => navigate(`/${appLabel}/${modelName}/${row.pk}`)}
emptyLabel={q || chips.length ? 'No results match these filters.' : 'No objects yet.'}
selectable={canRunActions}
selectedKeys={selected}
onToggleRow={toggleRow}
onToggleAll={(checked) => toggleAll(checked, data.results)}
/>
</Card>
<Pagination page={data.page} totalPages={totalPages} onChange={setPage} />
Expand Down
22 changes: 22 additions & 0 deletions frontend/packages/api/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// here. See `CLAUDE.md` §7.

import type {
ActionRunResponse,
CreatePayload,
CreateResponse,
DetailResponse,
Expand Down Expand Up @@ -175,4 +176,25 @@ export class ApiClient {
delete(appLabel: string, modelName: string, pk: string | number): Promise<void> {
return this.request<void>('DELETE', `${appLabel}/${modelName}/${pk}/`);
}

/**
* Run a `ModelAdmin` action over the selected rows (contract §5.4).
* The backend re-resolves the action name through
* `get_actions(request)` — the SPA name is never trusted as a
* callable lookup — and runs it over
* `get_queryset(request).filter(pk__in=pks)`.
*/
runAction(
appLabel: string,
modelName: string,
actionName: string,
pks: Array<string | number>,
confirmed = true,
): Promise<ActionRunResponse> {
return this.request<ActionRunResponse>(
'POST',
`${appLabel}/${modelName}/actions/${actionName}/`,
{ pks, confirmed },
);
}
}
Loading
Loading