From cc1ad0ef3c77e15dc994c67ca81214ebb1075554 Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs Date: Tue, 26 May 2026 23:17:41 +0200 Subject: [PATCH] feat(web): list toolbar (search-left + filter + actions) + detail inlines rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//, 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) --- frontend/apps/web/src/pages/DetailPage.tsx | 57 +++++- frontend/apps/web/src/pages/ListPage.tsx | 198 ++++++++++++++++----- frontend/packages/api/src/client.ts | 22 +++ frontend/packages/api/src/contract.ts | 57 ++++++ frontend/packages/data/src/index.ts | 4 + frontend/packages/ui/src/Table.tsx | 36 ++++ 6 files changed, 326 insertions(+), 48 deletions(-) diff --git a/frontend/apps/web/src/pages/DetailPage.tsx b/frontend/apps/web/src/pages/DetailPage.tsx index 90bfb6f..89d7770 100644 --- a/frontend/apps/web/src/pages/DetailPage.tsx +++ b/frontend/apps/web/src/pages/DetailPage.tsx @@ -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'; @@ -64,6 +64,59 @@ export function DetailPage() { ))} + + {/* 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) => ( + + ))} ); } + +function InlineSection({ inline }: { inline: InlineDescriptor }) { + if (inline.rows.length === 0) { + return ( + +

No {inline.label.toLowerCase()} yet.

+
+ ); + } + + if (inline.kind === 'tabular') { + const columns = inline.fields.map((f) => ({ + key: f.name, + header: f.label, + render: (row: (typeof inline.rows)[number]) => , + })); + return ( + + r.pk} /> + + ); + } + + // Stacked: one definition list per child row. + return ( + +
+ {inline.rows.map((row) => ( +
+ {inline.fields.map((f) => ( +
+
{f.label}
+
+ +
+
+ ))} +
+ ))} +
+
+ ); +} diff --git a/frontend/apps/web/src/pages/ListPage.tsx b/frontend/apps/web/src/pages/ListPage.tsx index 197225f..1aef5f0 100644 --- a/frontend/apps/web/src/pages/ListPage.tsx +++ b/frontend/apps/web/src/pages/ListPage.tsx @@ -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, @@ -46,7 +47,7 @@ export function ListPage() { return out; }, [searchParams]); - const { data, loading, error } = useList({ + const { data, loading, error, refresh } = useList({ client, appLabel, modelName, @@ -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>(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); @@ -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 { + 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 ; if (error && !data) { return ; @@ -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 (
-
-
-

- {appLabel} ·{' '} - {data.verbose_name_plural - ? capitalize(data.verbose_name_plural) - : data.object_name || modelName} -

-

- {data.total.toLocaleString()} object{data.total === 1 ? '' : 's'} -

-
-
- {data.search_fields.length > 0 && ( -
{ - e.preventDefault(); - commitSearch(); - }} - > - setSearchDraft(e.target.value)} - onBlur={commitSearch} - /> - - )} - {hasFilters && ( +
+

+ {appLabel} ·{' '} + {data.verbose_name_plural + ? capitalize(data.verbose_name_plural) + : data.object_name || modelName} +

+

+ {data.total.toLocaleString()} object{data.total === 1 ? '' : 's'} +

+
+ + {/* Toolbar row (#177 / #182): Actions dropdown (only when rows are + selected) + a left-aligned debounced search + the Filter + button that opens the modal. */} +
+ {canRunActions && selected.size > 0 && ( +
- )} -
-
+ {actionsOpen && ( +
+ {actions.map((a) => ( + + ))} +
+ )} +
+ )} + {data.search_fields.length > 0 && ( +
{ + e.preventDefault(); + commitSearch(); + }} + > + setSearchDraft(e.target.value)} + onBlur={commitSearch} + /> + + )} + {hasFilters && ( + + )} + {chips.length > 0 && (
@@ -184,7 +284,9 @@ export function ListPage() {
)} - {/* 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). */}
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)} /> diff --git a/frontend/packages/api/src/client.ts b/frontend/packages/api/src/client.ts index 1ef7ff6..66600c4 100644 --- a/frontend/packages/api/src/client.ts +++ b/frontend/packages/api/src/client.ts @@ -5,6 +5,7 @@ // here. See `CLAUDE.md` §7. import type { + ActionRunResponse, CreatePayload, CreateResponse, DetailResponse, @@ -175,4 +176,25 @@ export class ApiClient { delete(appLabel: string, modelName: string, pk: string | number): Promise { return this.request('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, + confirmed = true, + ): Promise { + return this.request( + 'POST', + `${appLabel}/${modelName}/actions/${actionName}/`, + { pks, confirmed }, + ); + } } diff --git a/frontend/packages/api/src/contract.ts b/frontend/packages/api/src/contract.ts index e04de7b..183aedb 100644 --- a/frontend/packages/api/src/contract.ts +++ b/frontend/packages/api/src/contract.ts @@ -143,6 +143,15 @@ export interface ActionDescriptor { requires_confirmation?: boolean; } +/** Result of running a bulk action (contract §5.4). */ +export interface ActionRunResponse { + executed: boolean; + action: string; + pks?: Array; + /** Present when the action callable returned an HttpResponse. */ + redirect?: string; +} + export interface ListResponse { app_label: string; /** Lowercase, no separators — used to build URLs. Do not display. */ @@ -189,6 +198,42 @@ export interface FieldsetDescriptor { fields: string[]; } +/** One field's header metadata inside an inline (read half). */ +export interface InlineFieldMeta { + name: string; + label: string; + readonly: boolean; +} + +/** One existing child row of an inline. */ +export interface InlineRow { + pk: number | string; + label: string; + fields: Record; +} + +/** + * One `InlineModelAdmin` surfaced on the detail response (Issue #54). + * `kind` drives the SPA layout: `tabular` → table rows, `stacked` → + * card stack. Permissions gate the edit affordances per-inline. + */ +export interface InlineDescriptor { + name: string; + label: string; + kind: 'tabular' | 'stacked'; + fk_name: string; + child: { app_label: string; model_name: string }; + extra: number; + min_num: number | null; + max_num: number | null; + can_view: boolean; + can_add: boolean; + can_change: boolean; + can_delete: boolean; + fields: InlineFieldMeta[]; + rows: InlineRow[]; +} + export interface DetailResponse { app_label: string; model_name: string; @@ -197,6 +242,8 @@ export interface DetailResponse { permissions: Permissions; fieldsets: FieldsetDescriptor[]; fields: Record; + /** `ModelAdmin.inlines` descriptors; always present (empty when none). */ + inlines: InlineDescriptor[]; } export interface CreateResponse { @@ -205,6 +252,16 @@ export interface CreateResponse { redirect: string; } +/** + * Response of `POST /api/v1/login/` (contract §7) — the package's React + * login endpoint returns the authenticated user block on success. The + * shape mirrors `RegistryUser` (the same minimal, self-known fields the + * registry exposes; no email / groups / perms). + */ +export interface LoginResponse { + user: RegistryUser; +} + export interface FieldErrorEnvelope { error: { code: string; diff --git a/frontend/packages/data/src/index.ts b/frontend/packages/data/src/index.ts index 2921b9a..df436cd 100644 --- a/frontend/packages/data/src/index.ts +++ b/frontend/packages/data/src/index.ts @@ -10,6 +10,7 @@ export type { ApiProviderProps } from './api-context'; export { ApiClient, ApiError } from '@dar/api'; export type { ActionDescriptor, + ActionRunResponse, ApiClientConfig, ColumnDescriptor, CreatePayload, @@ -25,6 +26,9 @@ export type { FilterOption, ForeignKeyValue, HtmlValue, + InlineDescriptor, + InlineFieldMeta, + InlineRow, ListResponse, ListRow, LoginResponse, diff --git a/frontend/packages/ui/src/Table.tsx b/frontend/packages/ui/src/Table.tsx index 6509608..7417564 100644 --- a/frontend/packages/ui/src/Table.tsx +++ b/frontend/packages/ui/src/Table.tsx @@ -21,6 +21,16 @@ export interface TableProps { sortDirection?: 'asc' | 'desc'; onRowClick?: (row: Row) => void; emptyLabel?: string; + /** + * When set, a leading checkbox column is rendered. `selectedKeys` + * holds the currently-selected row keys; `onToggleRow` toggles one + * row and `onToggleAll` toggles every row on the page. Generic, + * props-driven — no business knowledge in the primitive. + */ + selectable?: boolean; + selectedKeys?: Set; + onToggleRow?: (key: string | number) => void; + onToggleAll?: (checked: boolean) => void; } const ALIGN_CLASSES = { @@ -38,15 +48,31 @@ export function Table({ sortDirection, onRowClick, emptyLabel = 'No results.', + selectable = false, + selectedKeys, + onToggleRow, + onToggleAll, }: TableProps) { if (rows.length === 0) { return
{emptyLabel}
; } + const selected = selectedKeys ?? new Set(); + const allSelected = rows.length > 0 && rows.every((r) => selected.has(rowKey(r))); return (
+ {selectable && ( + + )} {columns.map((col) => { const align = ALIGN_CLASSES[col.align ?? 'left']; const sortable = col.sortable && onSort; @@ -77,6 +103,16 @@ export function Table({ onClick={onRowClick ? () => onRowClick(row) : undefined} className={onRowClick ? 'cursor-pointer hover:bg-gray-50' : ''} > + {selectable && ( + + )} {columns.map((col) => (
+ onToggleAll?.(e.target.checked)} + /> + e.stopPropagation()}> + onToggleRow?.(key)} + /> +