diff --git a/CHANGELOG.md b/CHANGELOG.md
index c18c027..cdcf783 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- **Split DetailPage/ListPage into focused modules (no behavior change)
+ (#657).** Extracted the inner components of `DetailPage.tsx`
+ (`DetailValue`, `FieldsetSection`, `ObjectActionButton`, `EditForm`,
+ `CustomViewsMenu`, `DeleteButton`, `CollapsedEmptyInline`,
+ `InlineSection`) into one-per-file modules under
+ `apps/web/src/pages/detail/`, and lifted `ListPage.tsx`'s `capitalize` /
+ `emptyLabel` helpers into `apps/web/src/pages/list/helpers.ts`. The page
+ components keep their original paths + named exports; runtime behavior,
+ props, and rendered output are unchanged.
- **Python lint stack consolidated onto Ruff (#651, #652).** Removed Black,
standalone isort, and flake8 entirely (their `[tool.*]` config, dev
dependencies, pre-commit hooks, and `scripts/lint.sh` steps). Ruff now owns
diff --git a/frontend/apps/web/src/pages/DetailPage.tsx b/frontend/apps/web/src/pages/DetailPage.tsx
index ebc97a6..fe411d0 100644
--- a/frontend/apps/web/src/pages/DetailPage.tsx
+++ b/frontend/apps/web/src/pages/DetailPage.tsx
@@ -8,20 +8,16 @@
// overlay, Esc / backdrop close), the same primitive the list's filter
// and bulk-action confirms use — then DELETEs and returns to the list.
// Edit/Delete are gated by the `permissions` block the API returns.
+//
+// The inner pieces (read-mode sections, the edit form, the toolbar
+// buttons) live in sibling modules under ./detail (#657); this file is
+// just the page that wires them together.
-import { useCallback, useEffect, useRef, useState } from 'react';
-import {
- ChevronDown,
- Clock,
- ExternalLink,
- Pencil,
- RefreshCw,
- Trash2,
-} from 'lucide-react';
+import { useState } from 'react';
+import { Clock, ExternalLink, Pencil, RefreshCw } from 'lucide-react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
- ApiError,
createObject,
deleteObject,
fetchDeletePreview,
@@ -29,22 +25,10 @@ import {
updateObject,
useApiClient,
useDetail,
- type CustomView,
- type DeletePreviewResponse,
- type DetailResponse,
- type FieldDescriptor,
- type FieldsetDescriptor,
- type InlineDescriptor,
- type InlineWriteItem,
- type InlineWritePayload,
- type ObjectActionDescriptor,
- type ObjectActionRunResponse,
type WriteValue,
} from '@dar/data';
-import { detailCollapseKey, usePersistedState } from '@dar/customization';
-import { Breadcrumb, Button, Card, EmptyState, Modal, RefreshButton, Table } from '@dar/ui';
-import { FieldValueView } from '@dar/details';
-import { FieldInput, InlineEditor } from '@dar/form';
+import { detailCollapseKey } from '@dar/customization';
+import { Breadcrumb, Button, EmptyState, RefreshButton } from '@dar/ui';
import { HistoryModal } from '@dar/history';
import { RecordSkeleton } from '../components/RecordSkeleton';
@@ -52,129 +36,12 @@ import { useModelMeta } from '../useModelMeta';
import { toastMessages, useToast } from '../toast';
import { followActionRedirect } from '../action-redirect';
import { carryPreservedFilters, listPathWithPreservedFilters } from '../changelistFilters';
-import { useUnsavedGuard } from '../useUnsavedGuard';
-
-// Render a detail field's value. ForeignKey values become a navigable
-// link to the related object's detail page (#184 — Django-admin
-// parity), using the descriptor's `to` (the FK target's real
-// app_label + model_name) so the URL round-trips through resolve_model.
-// Everything else defers to FieldValueView.
-function DetailValue({ field }: { field: FieldDescriptor }) {
- const v = field.value;
- if (
- field.type === 'foreignkey' &&
- field.to &&
- v &&
- typeof v === 'object' &&
- !Array.isArray(v) &&
- 'id' in v
- ) {
- const fk = v as { id: number | string; label: string };
- return (
-
- {fk.label}
-
- );
- }
- // Choice field: show the human label for the stored value (Django's
- // get_FOO_display parity). The editor still submits the raw value via
- // `field.value`; this only changes the read-mode rendering. Scalar
- // values only — FK / file / html envelopes are objects handled above
- // or by FieldValueView.
- if (field.choices && field.choices.length > 0 && v !== null && typeof v !== 'object') {
- const match = field.choices.find((o) => String(o.value) === String(v));
- if (match) return <>{match.label}>;
- }
- return ;
-}
-
-// Render one fieldset in the read view. Every section is collapsible
-// behind a caret (#359) and remembers its open/closed state per model +
-// section in localStorage. The default honours Django's fieldset
-// `classes` (#306): a `collapse` section starts collapsed, the rest
-// start open; any `description` shows as section help text under the
-// title. A saved preference (if present) wins over that default.
-function FieldsetSection({
- fieldset,
- fields,
- persistKey,
-}: {
- fieldset: FieldsetDescriptor;
- fields: Record;
- persistKey: string;
-}) {
- // Default open unless Django's `collapse` class says otherwise; a saved
- // preference wins. Persistence is centralized in @dar/customization.
- const startsCollapsed = (fieldset.classes ?? []).includes('collapse');
- const [open, setOpen] = usePersistedState(persistKey, !startsCollapsed);
- const toggle = (): void => setOpen((o) => !o);
-
- return (
-
-
- {open ? (
-
- {fieldset.description ? (
-
{fieldset.description}
- ) : null}
-
- {(fieldset.field_rows ?? fieldset.fields.map((f) => [f])).map((row, ri) => {
- // A single-field row keeps the wide label | value layout; a
- // multi-field row (Django tuple grouping, #382) lays its
- // fields side by side, each label-above-value.
- if (row.length === 1) {
- const field = fields[row[0] as string];
- if (!field) return null;
- return (
-
-
{field.label}
-
-
-
-
- );
- }
- return (
-
- {row.map((name) => {
- const field = fields[name];
- if (!field) return null;
- return (
-
-
{field.label}
-
-
-
-
- );
- })}
-
- );
- })}
-
-
- ) : null}
-
- );
-}
+import { CustomViewsMenu } from './detail/CustomViewsMenu';
+import { DeleteButton } from './detail/DeleteButton';
+import { EditForm } from './detail/EditForm';
+import { FieldsetSection } from './detail/FieldsetSection';
+import { InlineSection } from './detail/InlineSection';
+import { ObjectActionButton } from './detail/ObjectActionButton';
export interface DetailPageProps {
/** Open the page directly in edit mode (Django-admin URL alias
@@ -237,40 +104,30 @@ export function DetailPage({
return (
- {/* Header (#572 / #658): three stacked full-width rows so the
- title never shares horizontal space with the toolbar — the
- old side-by-side layout collapsed long single-token titles
- (filenames, slugs, UUIDs) into one-word-per-line at full H1
- size, AND let an 8+ action toolbar push the title clean off
- the viewport. Each concern now owns its own row:
-
- row 1: breadcrumb (full width, truncates on tight viewports)
- row 2: H1 (full width, `overflow-wrap: anywhere` so a
- single long token wraps inside the container)
- row 3: toolbar (full width, `flex-wrap` so 8+ actions flow
- to new lines; primary actions Edit/Delete
- sit at the trailing edge via `ml-auto`) */}
-
- (
-
- {label}
-
- )}
- />
- {/* `break-words` (Tailwind's overflow-wrap: anywhere) keeps a
- very long single-token title wrapping inside the container
- at H1 size, instead of forcing each hyphen segment onto its
- own line. `text-balance` rebalances the wrap for shorter
- multi-word titles too. */}
-
{data.label}
+ {/* Header (#572): the title is the page's most important element
+ and gets as much horizontal space as it needs (`flex-1
+ min-w-0`); the toolbar is `shrink-0` and only pushes the title
+ when it genuinely can't fit on its row. `justify-end` on the
+ toolbar's flex-wrap keeps wrapped button rows flush right to
+ the page padding, instead of left-aligned within their column. */}
+
+
+ (
+
+ {label}
+
+ )}
+ />
+
{data.label}
+
{!editing && (
-
+
)}
@@ -472,647 +321,3 @@ export function DetailPage({
);
}
-
-// One object-level change-page action button (#236). Disables + shows a
-// spinner while the POST is in flight; on success the parent re-fetches
-// the detail payload (so computed/readonly fields reflect the action) and
-// toasts, or navigates when the action returned a redirect. No full reload.
-function ObjectActionButton({
- action,
- onRun,
- onSuccess,
- onError,
-}: {
- action: ObjectActionDescriptor;
- onRun: () => Promise;
- onSuccess: (result: ObjectActionRunResponse) => Promise | void;
- onError: (message: string) => void;
-}) {
- const [busy, setBusy] = useState(false);
- return (
- {
- setBusy(true);
- try {
- const result = await onRun();
- if (result.ok) {
- await onSuccess(result);
- } else {
- onError(result.message || 'The action could not be completed.');
- }
- } catch (err) {
- // A raising action callable comes back as a 400 (never a 500);
- // the client throws an ApiError. The 400 body is `{ok, error}`,
- // and the backend keeps that message generic on purpose, so we
- // surface a friendly fallback rather than the raw "HTTP 400".
- if (err instanceof ApiError) {
- const raw = err.envelope as unknown as { error?: unknown } | null;
- const detail =
- typeof raw?.error === 'string' ? raw.error : err.envelope?.error?.message;
- onError(detail || 'The action could not be completed.');
- } else {
- onError(err instanceof Error ? err.message : 'The action could not be completed.');
- }
- } finally {
- setBusy(false);
- }
- }}
- >
- {action.label}
-
- );
-}
-
-// Which save-flow button was pressed (Django parity, #154). The parent
-// routes navigation per action; the form only builds + submits.
-type SaveAction = 'save' | 'continue' | 'addAnother' | 'saveAsNew';
-
-interface EditFormProps {
- data: DetailResponse;
- onCancel: () => void;
- onSave: (payload: import('@dar/data').UpdatePayload, action: SaveAction) => Promise;
-}
-
-function initialValueFor(field: DetailResponse['fields'][string]): WriteValue {
- const v = field.value;
- if (v === null || v === undefined) return null;
- if (field.type === 'json') {
- // JSON editor (#242): seed the textarea with the pretty-printed value
- // (a string) so an untouched field round-trips its existing JSON
- // intact instead of being wiped. Checked before the array branch so
- // a JSON array isn't mistaken for an M2M id list.
- return JSON.stringify(v, null, 2);
- }
- if (field.type === 'array') {
- // ArrayField editor (#242): seed the comma-joined value (string),
- // matching Django's SimpleArrayField widget. Checked before the M2M
- // array branch so the scalar list isn't mapped to {id} envelopes.
- return Array.isArray(v) ? v.join(',') : null;
- }
- if (field.type === 'range') {
- // RangeField editor (#242): unwrap the read envelope
- // `{subtype, value: {lower, upper, bounds}}` into the `[lower, upper]`
- // array shape `_range_endpoints` accepts (#533). Checked before the
- // generic object branch so the envelope isn't mistaken for an FK.
- if (v && typeof v === 'object' && 'value' in v) {
- const inner = (v as { value?: unknown }).value;
- if (inner && typeof inner === 'object') {
- const lower = (inner as { lower?: unknown }).lower;
- const upper = (inner as { upper?: unknown }).upper;
- return [
- lower == null ? '' : String(lower),
- upper == null ? '' : String(upper),
- ];
- }
- }
- return ['', ''];
- }
- if (Array.isArray(v)) {
- // M2M (#240): [{id,label}, ...] → [id, ...] (bare pks for the write).
- return v.map((item) =>
- item && typeof item === 'object' && 'id' in item ? item.id : (item as string | number),
- );
- }
- if (typeof v === 'object') {
- // FK envelope {id,label} → id; html → leave null (not edited here).
- if ('id' in v) return v.id;
- return null;
- }
- return v;
-}
-
-function EditForm({ data, onCancel, onSave }: EditFormProps) {
- const [values, setValues] = useState>(() => {
- const init: Record = {};
- for (const [name, field] of Object.entries(data.fields)) {
- if (!field.readonly) init[name] = initialValueFor(field);
- }
- return init;
- });
- const [errors, setErrors] = useState>({});
- const [nonFieldError, setNonFieldError] = useState(null);
- const [saving, setSaving] = useState(false);
- // Inline write items collected from each InlineEditor, keyed by name.
- const [inlineItems, setInlineItems] = useState>({});
-
- // Unsaved-changes guard (#290): snapshot the initial values once, then
- // warn on tab-close/reload while the form differs from that snapshot.
- const initialJsonRef = useRef(null);
- if (initialJsonRef.current === null) initialJsonRef.current = JSON.stringify(values);
- const dirty = JSON.stringify(values) !== initialJsonRef.current;
- useUnsavedGuard(dirty && !saving);
-
- // Stable so InlineEditor's effect doesn't re-fire every render.
- const handleInlineItems = useCallback((name: string, items: InlineWriteItem[]) => {
- setInlineItems((prev) => ({ ...prev, [name]: items }));
- }, []);
-
- // Inlines the user can actually modify (per-inline permission flags).
- const editableInlines = (data.inlines ?? []).filter(
- (inline) => inline.can_add || inline.can_change || inline.can_delete,
- );
-
- async function runSave(action: SaveAction) {
- setSaving(true);
- setErrors({});
- setNonFieldError(null);
- // Build the PATCH body: parent field values + any non-empty inline
- // blocks (api-contract §5.2.1). Empty inline blocks are omitted so
- // an untouched inline never posts.
- const payload: import('@dar/data').UpdatePayload = { ...values };
- const inlines: InlineWritePayload = {};
- for (const [name, items] of Object.entries(inlineItems)) {
- if (items.length > 0) inlines[name] = { items };
- }
- if (Object.keys(inlines).length > 0) payload.inlines = inlines;
- try {
- await onSave(payload, action);
- // Save succeeded — rebase the dirty snapshot so "Save and continue
- // editing" (form stays mounted) doesn't keep warning about edits
- // that are now persisted.
- initialJsonRef.current = JSON.stringify(values);
- } catch (err) {
- if (err instanceof ApiError && err.envelope?.error) {
- // Non-field errors (a ModelForm.clean() / __all__ cross-field
- // rule) arrive under the empty-string key (#381). Surface them
- // as the form-level banner and render the named field errors
- // inline — both can show at once.
- const { ['']: nonField, ...namedErrors } = err.envelope.error.fields ?? {};
- setErrors(namedErrors);
- const banner =
- nonField?.join(' ') ||
- (Object.keys(namedErrors).length === 0 ? err.envelope.error.message : '');
- setNonFieldError(banner || null);
- } else {
- setNonFieldError(err instanceof Error ? err.message : 'Save failed.');
- }
- } finally {
- setSaving(false);
- }
- }
-
- async function handleSubmit(e: React.FormEvent) {
- e.preventDefault();
- await runSave('save');
- }
-
- const so = data.save_options;
-
- // Save-flow buttons (#154) — render only what `save_options` allows;
- // default to a plain Save for older backends. Built as a function so it
- // can be rendered both at the top (when `save_on_top`, #251) and bottom.
- const renderSaveActions = () => (
-
- {(so?.show_save ?? true) && (
-
- {saving ? 'Saving…' : 'Save'}
-
- )}
- {so?.show_save_and_continue && (
- void runSave('continue')}
- >
- Save and continue editing
-
- )}
- {so?.show_save_and_add_another && (
- void runSave('addAnother')}
- >
- Save and add another
-
- )}
- {so?.show_save_as_new && (
- void runSave('saveAsNew')}
- >
- Save as new
-
- )}
-
- Cancel
-
-
- );
-
- return (
-
- );
-}
-
-// Custom admin views (#439): bespoke admin pages the consumer wired via
-// ModelAdmin.get_urls(). The SPA can't render the Django template, so it
-// links out — a real anchor that opens the legacy-admin-rendered page in
-// a new tab. A single view renders as one button; several collapse into
-// an unobtrusive "More" dropdown so the toolbar stays tidy. Closes on
-// outside-click / Escape.
-function CustomViewsMenu({ views }: { views: CustomView[] }) {
- const [open, setOpen] = useState(false);
- const ref = useRef(null);
-
- useEffect(() => {
- if (!open) return undefined;
- const onDocClick = (e: MouseEvent): void => {
- if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
- };
- const onKey = (e: KeyboardEvent): void => {
- if (e.key === 'Escape') setOpen(false);
- };
- document.addEventListener('mousedown', onDocClick);
- document.addEventListener('keydown', onKey);
- return () => {
- document.removeEventListener('mousedown', onDocClick);
- document.removeEventListener('keydown', onKey);
- };
- }, [open]);
-
- const linkClass =
- 'flex items-center gap-1.5 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap';
-
- // Single view → render it inline as one button (no need for a menu).
- if (views.length === 1) {
- const v = views[0] as CustomView;
- return (
-
- {v.label}
-
- );
- }
-
- return (
-
- You don’t have permission to delete: {preview.perms_needed.join(', ')}.
-
- )}
-
- {err &&
{err}
}
-
- )}
- >
- );
-}
-
-// CollapsedEmptyInline (#591) — a slim card showing only the inline's
-// title + a caret toggle. The body (the "No X yet" copy + a hint to
-// enter edit mode) is hidden by default; clicking the title expands
-// it. Default-collapsed every page load — per-user persistence isn't
-// worth the storage for a detail-page hint.
-function CollapsedEmptyInline({ label }: { label: string }) {
- const [open, setOpen] = useState(false);
- return (
-
- setOpen((o) => !o)}
- aria-expanded={open}
- className="flex w-full items-center justify-between gap-2 text-left text-base font-semibold text-gray-900"
- >
- {label}
-
-
- {open ? (
-
- No {label.toLowerCase()} yet. Click Edit to
- add the first one.
-
- ) : null}
-
- );
-}
-
-function InlineSection({ inline }: { inline: InlineDescriptor }) {
- // Empty inline (#591):
- // - Not addable → hide the whole section (Option A). Empty + read-only
- // has zero information value and just lengthens the page.
- // - Addable → render as a single-line collapsed card with a caret
- // (Option B). The operator can see the inline EXISTS (so they
- // know to click Edit to add a first child) but the "No X yet"
- // placeholder no longer eats vertical space on every load.
- if (inline.rows.length === 0) {
- if (!inline.can_add) return null;
- return ;
- }
-
- // Per-row link to the child's own change page (#384 — Django's
- // InlineModelAdmin.show_change_link). The backend only sets the flag
- // when the child is registered, so the target always resolves.
- const changeLinkTo = (pk: string | number): string =>
- `/${inline.child.app_label}/${inline.child.model_name}/${pk}`;
-
- if (inline.kind === 'tabular') {
- const columns = [
- ...inline.fields.map((f) => ({
- key: f.name,
- header: f.label,
- // The pk column never truncates (#418) — a UUID/explicit pk is the
- // row's identity and link target and must stay fully readable.
- noTruncate: f.name === inline.pk_field,
- render: (row: (typeof inline.rows)[number]) => (
-
- ),
- })),
- ...(inline.show_change_link
- ? [
- {
- key: '__change_link',
- header: '',
- render: (row: (typeof inline.rows)[number]) => (
-
- Edit
-
- ),
- },
- ]
- : []),
- ];
- return (
-
-
r.pk} />
-
- );
- }
-
- // Stacked: one definition list per child row.
- return (
-
-
- {inline.rows.map((row) => (
-
-
- {inline.fields.map((f) => (
-
-
{f.label}
-
-
-
-
- ))}
-
- {inline.show_change_link && (
-
-
- Edit
-
-
- )}
-
- ))}
-
-
- );
-}
diff --git a/frontend/apps/web/src/pages/ListPage.tsx b/frontend/apps/web/src/pages/ListPage.tsx
index 9850192..93f2eb6 100644
--- a/frontend/apps/web/src/pages/ListPage.tsx
+++ b/frontend/apps/web/src/pages/ListPage.tsx
@@ -42,6 +42,7 @@ import { FilterBar } from '@dar/search';
import { useToast } from '../toast';
import { CHANGELIST_FILTERS_PARAM, withPreservedFilters } from '../changelistFilters';
import { handleActionResult } from './action-result';
+import { capitalize, emptyLabel } from './list/helpers';
// Lazy-loaded so the @dnd-kit suite (the heaviest dep in this modal)
// only lands in the bundle of users who open the Customize modal
@@ -871,16 +872,3 @@ export function ListPage() {
);
}
-
-function capitalize(value: string): string {
- if (!value) return value;
- return value.charAt(0).toUpperCase() + value.slice(1);
-}
-
-// Empty-state copy. When a search / filter is active, say so; otherwise a
-// plain "No objects yet." An active server-side default filter is surfaced
-// by the Filter button + modal (see #283), not by over-explaining it here.
-function emptyLabel(hasQuery: boolean, chipCount: number): string {
- if (hasQuery || chipCount > 0) return 'No results match the current search / filters.';
- return 'No objects yet.';
-}
diff --git a/frontend/apps/web/src/pages/detail/CollapsedEmptyInline.tsx b/frontend/apps/web/src/pages/detail/CollapsedEmptyInline.tsx
new file mode 100644
index 0000000..347e28c
--- /dev/null
+++ b/frontend/apps/web/src/pages/detail/CollapsedEmptyInline.tsx
@@ -0,0 +1,37 @@
+import { useState } from 'react';
+import { ChevronDown } from 'lucide-react';
+
+import { Card } from '@dar/ui';
+
+/**
+ * CollapsedEmptyInline (#591) — a slim card showing only the inline's
+ * title + a caret toggle. The body (the "No X yet" copy + a hint to
+ * enter edit mode) is hidden by default; clicking the title expands
+ * it. Default-collapsed every page load — per-user persistence isn't
+ * worth the storage for a detail-page hint.
+ */
+export function CollapsedEmptyInline({ label }: { label: string }) {
+ const [open, setOpen] = useState(false);
+ return (
+
+ setOpen((o) => !o)}
+ aria-expanded={open}
+ className="flex w-full items-center justify-between gap-2 text-left text-base font-semibold text-gray-900"
+ >
+ {label}
+
+
+ {open ? (
+
+ No {label.toLowerCase()} yet. Click Edit to
+ add the first one.
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/apps/web/src/pages/detail/CustomViewsMenu.tsx b/frontend/apps/web/src/pages/detail/CustomViewsMenu.tsx
new file mode 100644
index 0000000..7ffb1f3
--- /dev/null
+++ b/frontend/apps/web/src/pages/detail/CustomViewsMenu.tsx
@@ -0,0 +1,89 @@
+import { useEffect, useRef, useState } from 'react';
+import { ChevronDown, ExternalLink } from 'lucide-react';
+
+import { type CustomView } from '@dar/data';
+
+/**
+ * Custom admin views (#439): bespoke admin pages the consumer wired via
+ * ModelAdmin.get_urls(). The SPA can't render the Django template, so it
+ * links out — a real anchor that opens the legacy-admin-rendered page in
+ * a new tab. A single view renders as one button; several collapse into
+ * an unobtrusive "More" dropdown so the toolbar stays tidy. Closes on
+ * outside-click / Escape.
+ */
+export function CustomViewsMenu({ views }: { views: CustomView[] }) {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (!open) return undefined;
+ const onDocClick = (e: MouseEvent): void => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+ };
+ const onKey = (e: KeyboardEvent): void => {
+ if (e.key === 'Escape') setOpen(false);
+ };
+ document.addEventListener('mousedown', onDocClick);
+ document.addEventListener('keydown', onKey);
+ return () => {
+ document.removeEventListener('mousedown', onDocClick);
+ document.removeEventListener('keydown', onKey);
+ };
+ }, [open]);
+
+ const linkClass =
+ 'flex items-center gap-1.5 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap';
+
+ // Single view → render it inline as one button (no need for a menu).
+ if (views.length === 1) {
+ const v = views[0] as CustomView;
+ return (
+
+ {v.label}
+
+ );
+ }
+
+ return (
+
+ You don’t have permission to delete: {preview.perms_needed.join(', ')}.
+
+ )}
+
+ {err &&
{err}
}
+
+ )}
+ >
+ );
+}
diff --git a/frontend/apps/web/src/pages/detail/DetailValue.tsx b/frontend/apps/web/src/pages/detail/DetailValue.tsx
new file mode 100644
index 0000000..486dbac
--- /dev/null
+++ b/frontend/apps/web/src/pages/detail/DetailValue.tsx
@@ -0,0 +1,43 @@
+import { Link } from 'react-router-dom';
+
+import { type FieldDescriptor } from '@dar/data';
+import { FieldValueView } from '@dar/details';
+
+/**
+ * Render a detail field's value. ForeignKey values become a navigable
+ * link to the related object's detail page (#184 — Django-admin
+ * parity), using the descriptor's `to` (the FK target's real
+ * app_label + model_name) so the URL round-trips through resolve_model.
+ * Everything else defers to FieldValueView.
+ */
+export function DetailValue({ field }: { field: FieldDescriptor }) {
+ const v = field.value;
+ if (
+ field.type === 'foreignkey' &&
+ field.to &&
+ v &&
+ typeof v === 'object' &&
+ !Array.isArray(v) &&
+ 'id' in v
+ ) {
+ const fk = v as { id: number | string; label: string };
+ return (
+
+ {fk.label}
+
+ );
+ }
+ // Choice field: show the human label for the stored value (Django's
+ // get_FOO_display parity). The editor still submits the raw value via
+ // `field.value`; this only changes the read-mode rendering. Scalar
+ // values only — FK / file / html envelopes are objects handled above
+ // or by FieldValueView.
+ if (field.choices && field.choices.length > 0 && v !== null && typeof v !== 'object') {
+ const match = field.choices.find((o) => String(o.value) === String(v));
+ if (match) return <>{match.label}>;
+ }
+ return ;
+}
diff --git a/frontend/apps/web/src/pages/detail/EditForm.tsx b/frontend/apps/web/src/pages/detail/EditForm.tsx
new file mode 100644
index 0000000..b84e06b
--- /dev/null
+++ b/frontend/apps/web/src/pages/detail/EditForm.tsx
@@ -0,0 +1,274 @@
+import { useCallback, useRef, useState } from 'react';
+
+import {
+ ApiError,
+ type DetailResponse,
+ type InlineWriteItem,
+ type InlineWritePayload,
+ type WriteValue,
+} from '@dar/data';
+import { Button, Card } from '@dar/ui';
+import { FieldInput, InlineEditor } from '@dar/form';
+
+import { useUnsavedGuard } from '../../useUnsavedGuard';
+
+/**
+ * Which save-flow button was pressed (Django parity, #154). The parent
+ * routes navigation per action; the form only builds + submits.
+ */
+export type SaveAction = 'save' | 'continue' | 'addAnother' | 'saveAsNew';
+
+export interface EditFormProps {
+ data: DetailResponse;
+ onCancel: () => void;
+ onSave: (payload: import('@dar/data').UpdatePayload, action: SaveAction) => Promise;
+}
+
+function initialValueFor(field: DetailResponse['fields'][string]): WriteValue {
+ const v = field.value;
+ if (v === null || v === undefined) return null;
+ if (field.type === 'json') {
+ // JSON editor (#242): seed the textarea with the pretty-printed value
+ // (a string) so an untouched field round-trips its existing JSON
+ // intact instead of being wiped. Checked before the array branch so
+ // a JSON array isn't mistaken for an M2M id list.
+ return JSON.stringify(v, null, 2);
+ }
+ if (field.type === 'array') {
+ // ArrayField editor (#242): seed the comma-joined value (string),
+ // matching Django's SimpleArrayField widget. Checked before the M2M
+ // array branch so the scalar list isn't mapped to {id} envelopes.
+ return Array.isArray(v) ? v.join(',') : null;
+ }
+ if (field.type === 'range') {
+ // RangeField editor (#242): unwrap the read envelope
+ // `{subtype, value: {lower, upper, bounds}}` into the `[lower, upper]`
+ // array shape `_range_endpoints` accepts (#533). Checked before the
+ // generic object branch so the envelope isn't mistaken for an FK.
+ if (v && typeof v === 'object' && 'value' in v) {
+ const inner = (v as { value?: unknown }).value;
+ if (inner && typeof inner === 'object') {
+ const lower = (inner as { lower?: unknown }).lower;
+ const upper = (inner as { upper?: unknown }).upper;
+ return [
+ lower == null ? '' : String(lower),
+ upper == null ? '' : String(upper),
+ ];
+ }
+ }
+ return ['', ''];
+ }
+ if (Array.isArray(v)) {
+ // M2M (#240): [{id,label}, ...] → [id, ...] (bare pks for the write).
+ return v.map((item) =>
+ item && typeof item === 'object' && 'id' in item ? item.id : (item as string | number),
+ );
+ }
+ if (typeof v === 'object') {
+ // FK envelope {id,label} → id; html → leave null (not edited here).
+ if ('id' in v) return v.id;
+ return null;
+ }
+ return v;
+}
+
+/**
+ * Edit-mode form for a detail object. Seeds each editable field from the
+ * read payload, PATCHes via `onSave`, and surfaces field-level + non-field
+ * validation errors. Renders the save-flow buttons `save_options` allows
+ * (#154) and collects editable inline blocks for the write (#54).
+ */
+export function EditForm({ data, onCancel, onSave }: EditFormProps) {
+ const [values, setValues] = useState>(() => {
+ const init: Record = {};
+ for (const [name, field] of Object.entries(data.fields)) {
+ if (!field.readonly) init[name] = initialValueFor(field);
+ }
+ return init;
+ });
+ const [errors, setErrors] = useState>({});
+ const [nonFieldError, setNonFieldError] = useState(null);
+ const [saving, setSaving] = useState(false);
+ // Inline write items collected from each InlineEditor, keyed by name.
+ const [inlineItems, setInlineItems] = useState>({});
+
+ // Unsaved-changes guard (#290): snapshot the initial values once, then
+ // warn on tab-close/reload while the form differs from that snapshot.
+ const initialJsonRef = useRef(null);
+ if (initialJsonRef.current === null) initialJsonRef.current = JSON.stringify(values);
+ const dirty = JSON.stringify(values) !== initialJsonRef.current;
+ useUnsavedGuard(dirty && !saving);
+
+ // Stable so InlineEditor's effect doesn't re-fire every render.
+ const handleInlineItems = useCallback((name: string, items: InlineWriteItem[]) => {
+ setInlineItems((prev) => ({ ...prev, [name]: items }));
+ }, []);
+
+ // Inlines the user can actually modify (per-inline permission flags).
+ const editableInlines = (data.inlines ?? []).filter(
+ (inline) => inline.can_add || inline.can_change || inline.can_delete,
+ );
+
+ async function runSave(action: SaveAction) {
+ setSaving(true);
+ setErrors({});
+ setNonFieldError(null);
+ // Build the PATCH body: parent field values + any non-empty inline
+ // blocks (api-contract §5.2.1). Empty inline blocks are omitted so
+ // an untouched inline never posts.
+ const payload: import('@dar/data').UpdatePayload = { ...values };
+ const inlines: InlineWritePayload = {};
+ for (const [name, items] of Object.entries(inlineItems)) {
+ if (items.length > 0) inlines[name] = { items };
+ }
+ if (Object.keys(inlines).length > 0) payload.inlines = inlines;
+ try {
+ await onSave(payload, action);
+ // Save succeeded — rebase the dirty snapshot so "Save and continue
+ // editing" (form stays mounted) doesn't keep warning about edits
+ // that are now persisted.
+ initialJsonRef.current = JSON.stringify(values);
+ } catch (err) {
+ if (err instanceof ApiError && err.envelope?.error) {
+ // Non-field errors (a ModelForm.clean() / __all__ cross-field
+ // rule) arrive under the empty-string key (#381). Surface them
+ // as the form-level banner and render the named field errors
+ // inline — both can show at once.
+ const { ['']: nonField, ...namedErrors } = err.envelope.error.fields ?? {};
+ setErrors(namedErrors);
+ const banner =
+ nonField?.join(' ') ||
+ (Object.keys(namedErrors).length === 0 ? err.envelope.error.message : '');
+ setNonFieldError(banner || null);
+ } else {
+ setNonFieldError(err instanceof Error ? err.message : 'Save failed.');
+ }
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ await runSave('save');
+ }
+
+ const so = data.save_options;
+
+ // Save-flow buttons (#154) — render only what `save_options` allows;
+ // default to a plain Save for older backends. Built as a function so it
+ // can be rendered both at the top (when `save_on_top`, #251) and bottom.
+ const renderSaveActions = () => (
+
+ {(so?.show_save ?? true) && (
+
+ {saving ? 'Saving…' : 'Save'}
+
+ )}
+ {so?.show_save_and_continue && (
+ void runSave('continue')}
+ >
+ Save and continue editing
+
+ )}
+ {so?.show_save_and_add_another && (
+ void runSave('addAnother')}
+ >
+ Save and add another
+
+ )}
+ {so?.show_save_as_new && (
+ void runSave('saveAsNew')}
+ >
+ Save as new
+
+ )}
+
+ Cancel
+
+
+ );
+
+ return (
+
+ );
+}
diff --git a/frontend/apps/web/src/pages/detail/FieldsetSection.tsx b/frontend/apps/web/src/pages/detail/FieldsetSection.tsx
new file mode 100644
index 0000000..2193e08
--- /dev/null
+++ b/frontend/apps/web/src/pages/detail/FieldsetSection.tsx
@@ -0,0 +1,94 @@
+import { ChevronDown } from 'lucide-react';
+
+import { type FieldDescriptor, type FieldsetDescriptor } from '@dar/data';
+import { usePersistedState } from '@dar/customization';
+import { Card } from '@dar/ui';
+
+import { DetailValue } from './DetailValue';
+
+/**
+ * Render one fieldset in the read view. Every section is collapsible
+ * behind a caret (#359) and remembers its open/closed state per model +
+ * section in localStorage. The default honours Django's fieldset
+ * `classes` (#306): a `collapse` section starts collapsed, the rest
+ * start open; any `description` shows as section help text under the
+ * title. A saved preference (if present) wins over that default.
+ */
+export function FieldsetSection({
+ fieldset,
+ fields,
+ persistKey,
+}: {
+ fieldset: FieldsetDescriptor;
+ fields: Record;
+ persistKey: string;
+}) {
+ // Default open unless Django's `collapse` class says otherwise; a saved
+ // preference wins. Persistence is centralized in @dar/customization.
+ const startsCollapsed = (fieldset.classes ?? []).includes('collapse');
+ const [open, setOpen] = usePersistedState(persistKey, !startsCollapsed);
+ const toggle = (): void => setOpen((o) => !o);
+
+ return (
+
+
+ {fieldset.title ?? 'Details'}
+
+
+ {open ? (
+
+ {fieldset.description ? (
+
{fieldset.description}
+ ) : null}
+
+ {(fieldset.field_rows ?? fieldset.fields.map((f) => [f])).map((row, ri) => {
+ // A single-field row keeps the wide label | value layout; a
+ // multi-field row (Django tuple grouping, #382) lays its
+ // fields side by side, each label-above-value.
+ if (row.length === 1) {
+ const field = fields[row[0] as string];
+ if (!field) return null;
+ return (
+
+
{field.label}
+
+
+
+
+ );
+ }
+ return (
+
+ {row.map((name) => {
+ const field = fields[name];
+ if (!field) return null;
+ return (
+
+
{field.label}
+
+
+
+
+ );
+ })}
+
+ );
+ })}
+
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/apps/web/src/pages/detail/InlineSection.tsx b/frontend/apps/web/src/pages/detail/InlineSection.tsx
new file mode 100644
index 0000000..a910174
--- /dev/null
+++ b/frontend/apps/web/src/pages/detail/InlineSection.tsx
@@ -0,0 +1,94 @@
+import { Link } from 'react-router-dom';
+
+import { type InlineDescriptor } from '@dar/data';
+import { Card, Table } from '@dar/ui';
+import { FieldValueView } from '@dar/details';
+
+import { CollapsedEmptyInline } from './CollapsedEmptyInline';
+
+/**
+ * Render one inline section in the read view (#54). Tabular inlines become
+ * a table, stacked inlines a card stack; an empty addable inline collapses
+ * to a single-line caret card (#591) and an empty non-addable one is hidden.
+ */
+export function InlineSection({ inline }: { inline: InlineDescriptor }) {
+ // Empty inline (#591):
+ // - Not addable → hide the whole section (Option A). Empty + read-only
+ // has zero information value and just lengthens the page.
+ // - Addable → render as a single-line collapsed card with a caret
+ // (Option B). The operator can see the inline EXISTS (so they
+ // know to click Edit to add a first child) but the "No X yet"
+ // placeholder no longer eats vertical space on every load.
+ if (inline.rows.length === 0) {
+ if (!inline.can_add) return null;
+ return ;
+ }
+
+ // Per-row link to the child's own change page (#384 — Django's
+ // InlineModelAdmin.show_change_link). The backend only sets the flag
+ // when the child is registered, so the target always resolves.
+ const changeLinkTo = (pk: string | number): string =>
+ `/${inline.child.app_label}/${inline.child.model_name}/${pk}`;
+
+ if (inline.kind === 'tabular') {
+ const columns = [
+ ...inline.fields.map((f) => ({
+ key: f.name,
+ header: f.label,
+ // The pk column never truncates (#418) — a UUID/explicit pk is the
+ // row's identity and link target and must stay fully readable.
+ noTruncate: f.name === inline.pk_field,
+ render: (row: (typeof inline.rows)[number]) => (
+
+ ),
+ })),
+ ...(inline.show_change_link
+ ? [
+ {
+ key: '__change_link',
+ header: '',
+ render: (row: (typeof inline.rows)[number]) => (
+
+ Edit
+
+ ),
+ },
+ ]
+ : []),
+ ];
+ return (
+
+
r.pk} />
+
+ );
+ }
+
+ // Stacked: one definition list per child row.
+ return (
+
+
+ {inline.rows.map((row) => (
+
+
+ {inline.fields.map((f) => (
+
+
{f.label}
+
+
+
+
+ ))}
+
+ {inline.show_change_link && (
+
+
+ Edit
+
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/apps/web/src/pages/detail/ObjectActionButton.tsx b/frontend/apps/web/src/pages/detail/ObjectActionButton.tsx
new file mode 100644
index 0000000..3d836a4
--- /dev/null
+++ b/frontend/apps/web/src/pages/detail/ObjectActionButton.tsx
@@ -0,0 +1,64 @@
+import { useState } from 'react';
+
+import {
+ ApiError,
+ type ObjectActionDescriptor,
+ type ObjectActionRunResponse,
+} from '@dar/data';
+import { Button } from '@dar/ui';
+
+/**
+ * One object-level change-page action button (#236). Disables + shows a
+ * spinner while the POST is in flight; on success the parent re-fetches
+ * the detail payload (so computed/readonly fields reflect the action) and
+ * toasts, or navigates when the action returned a redirect. No full reload.
+ */
+export function ObjectActionButton({
+ action,
+ onRun,
+ onSuccess,
+ onError,
+}: {
+ action: ObjectActionDescriptor;
+ onRun: () => Promise;
+ onSuccess: (result: ObjectActionRunResponse) => Promise | void;
+ onError: (message: string) => void;
+}) {
+ const [busy, setBusy] = useState(false);
+ return (
+ {
+ setBusy(true);
+ try {
+ const result = await onRun();
+ if (result.ok) {
+ await onSuccess(result);
+ } else {
+ onError(result.message || 'The action could not be completed.');
+ }
+ } catch (err) {
+ // A raising action callable comes back as a 400 (never a 500);
+ // the client throws an ApiError. The 400 body is `{ok, error}`,
+ // and the backend keeps that message generic on purpose, so we
+ // surface a friendly fallback rather than the raw "HTTP 400".
+ if (err instanceof ApiError) {
+ const raw = err.envelope as unknown as { error?: unknown } | null;
+ const detail =
+ typeof raw?.error === 'string' ? raw.error : err.envelope?.error?.message;
+ onError(detail || 'The action could not be completed.');
+ } else {
+ onError(err instanceof Error ? err.message : 'The action could not be completed.');
+ }
+ } finally {
+ setBusy(false);
+ }
+ }}
+ >
+ {action.label}
+
+ );
+}
diff --git a/frontend/apps/web/src/pages/list/helpers.ts b/frontend/apps/web/src/pages/list/helpers.ts
new file mode 100644
index 0000000..538eb26
--- /dev/null
+++ b/frontend/apps/web/src/pages/list/helpers.ts
@@ -0,0 +1,15 @@
+/** Capitalize the first character of a string (verbose-name display). */
+export function capitalize(value: string): string {
+ if (!value) return value;
+ return value.charAt(0).toUpperCase() + value.slice(1);
+}
+
+/**
+ * Empty-state copy. When a search / filter is active, say so; otherwise a
+ * plain "No objects yet." An active server-side default filter is surfaced
+ * by the Filter button + modal (see #283), not by over-explaining it here.
+ */
+export function emptyLabel(hasQuery: boolean, chipCount: number): string {
+ if (hasQuery || chipCount > 0) return 'No results match the current search / filters.';
+ return 'No objects yet.';
+}