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 && ( -
+
+ )} + {canDelete && ( + fetchDeletePreview({ client, appLabel, modelName, pk })} + onConfirm={async () => { + await deleteObject({ client, appLabel, modelName, pk }); + toast.success(`Deleted “${data.label}”.`); + navigate(listPath); + }} /> - {canChange && ( - - )} - {canDelete && ( - fetchDeletePreview({ client, appLabel, modelName, pk })} - onConfirm={async () => { - await deleteObject({ client, appLabel, modelName, pk }); - toast.success(`Deleted “${data.label}”.`); - navigate(listPath); - }} - /> - )} -
+ )}
)}
@@ -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 ( - - ); -} - -// 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) && ( - - )} - {so?.show_save_and_continue && ( - - )} - {so?.show_save_and_add_another && ( - - )} - {so?.show_save_as_new && ( - - )} - -
- ); - - return ( -
- {nonFieldError && ( -
- {nonFieldError} -
- )} - {so?.save_on_top && renderSaveActions()} - {data.fieldsets.map((fieldset, idx) => { - // Edit mode shows only fields the operator can actually change: - // drop readonly fields from each row, then drop now-empty rows. - // A fieldset left with nothing editable is hidden entirely (no - // empty card). - const editableRows = (fieldset.field_rows ?? fieldset.fields.map((f) => [f])) - .map((row) => row.filter((name) => data.fields[name] && !data.fields[name]?.readonly)) - .filter((row) => row.length > 0); - if (editableRows.length === 0) return null; - - const renderInput = (name: string) => { - const field = data.fields[name]; - if (!field) return null; - return ( - setValues((prev) => ({ ...prev, [name]: v }))} - /> - ); - }; - - return ( - -
- {editableRows.map((row, ri) => { - if (row.length === 1) return renderInput(row[0] as string); - // Multi-field row (#382): inputs side by side. - return ( -
- {row.map((name) => renderInput(name))} -
- ); - })} -
-
- ); - })} - - {/* Editable inlines (#54 write half) — typed inputs per child row, - add/remove, submitted via the PATCH `inlines` block. */} - {editableInlines.map((inline) => ( - - - {errors[`inlines.${inline.name}`]?.map((msg, i) => ( -

- {msg} -

- ))} -
- ))} - - {renderSaveActions()} - - ); -} - -// 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 ( -
- - {open && ( -
- {views.map((v) => ( - setOpen(false)} - > - {v.label} - - ))} -
- )} -
- ); -} - -interface DeleteButtonProps { - label: string; - loadPreview: () => Promise; - onConfirm: () => Promise; -} - -// Delete affordance: a danger button that opens a confirm dialog (the -// shared @dar/ui Modal). On open it fetches the cascade preview (#153 / -// Django admin's delete-confirmation parity) so the operator sees what -// else gets removed, what's PROTECT-blocked, and which extra delete -// perms are missing BEFORE the single destructive click. The Delete -// button is disabled while the preview says `can_delete: false` -// (protected rows or missing perms). The preview fetch is best-effort: -// if it fails, the dialog degrades to the plain confirm rather than -// blocking a legitimate delete. While the DELETE is in flight the modal -// can't be dismissed so it can't be double-fired. -function DeleteButton({ label, loadPreview, onConfirm }: DeleteButtonProps) { - const [open, setOpen] = useState(false); - const [busy, setBusy] = useState(false); - const [err, setErr] = useState(null); - const [preview, setPreview] = useState(null); - const [previewLoading, setPreviewLoading] = useState(false); - - // Latest-ref so the fetch fires only when the modal *opens*, not on - // every parent re-render (e.g. the background list/detail refetch). - const loadRef = useRef(loadPreview); - loadRef.current = loadPreview; - - useEffect(() => { - if (!open) return undefined; - let cancelled = false; - setPreviewLoading(true); - loadRef - .current() - .then((p) => { - if (!cancelled) setPreview(p); - }) - .catch(() => { - // Best-effort: a failed preview must not block a valid delete. - if (!cancelled) setPreview(null); - }) - .finally(() => { - if (!cancelled) setPreviewLoading(false); - }); - return () => { - cancelled = true; - }; - }, [open]); - - const close = () => { - if (busy) return; - setOpen(false); - setErr(null); - setPreview(null); - }; - - // Block the destructive action only when the preview positively says - // so — never when it's still loading or failed to load. - const blocked = preview !== null && !preview.can_delete; - - return ( - <> - - {open && ( - - - - - } - > -

- Are you sure you want to delete “{label}”? This - action cannot be undone. -

- - {previewLoading && ( -

Checking what this affects…

- )} - - {preview && preview.cascade.length > 0 && ( -
-

This will also delete:

-
    - {preview.cascade.map((c) => ( -
  • - {c.count} {c.model} -
  • - ))} -
-
- )} - - {preview && preview.protected.length > 0 && ( -
-

Blocked — protected related objects:

-
    - {preview.protected.map((p) => ( -
  • {p}
  • - ))} -
-
- )} - - {preview && preview.perms_needed.length > 0 && ( -
- 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 ( - - - {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 ( + + + {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 ( +
+ + {open && ( +
+ {views.map((v) => ( + setOpen(false)} + > + {v.label} + + ))} +
+ )} +
+ ); +} diff --git a/frontend/apps/web/src/pages/detail/DeleteButton.tsx b/frontend/apps/web/src/pages/detail/DeleteButton.tsx new file mode 100644 index 0000000..c8aaf22 --- /dev/null +++ b/frontend/apps/web/src/pages/detail/DeleteButton.tsx @@ -0,0 +1,148 @@ +import { useEffect, useRef, useState } from 'react'; +import { Trash2 } from 'lucide-react'; + +import { type DeletePreviewResponse } from '@dar/data'; +import { Button, Modal } from '@dar/ui'; + +interface DeleteButtonProps { + label: string; + loadPreview: () => Promise; + onConfirm: () => Promise; +} + +/** + * Delete affordance: a danger button that opens a confirm dialog (the + * shared @dar/ui Modal). On open it fetches the cascade preview (#153 / + * Django admin's delete-confirmation parity) so the operator sees what + * else gets removed, what's PROTECT-blocked, and which extra delete + * perms are missing BEFORE the single destructive click. The Delete + * button is disabled while the preview says `can_delete: false` + * (protected rows or missing perms). The preview fetch is best-effort: + * if it fails, the dialog degrades to the plain confirm rather than + * blocking a legitimate delete. While the DELETE is in flight the modal + * can't be dismissed so it can't be double-fired. + */ +export function DeleteButton({ label, loadPreview, onConfirm }: DeleteButtonProps) { + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + + // Latest-ref so the fetch fires only when the modal *opens*, not on + // every parent re-render (e.g. the background list/detail refetch). + const loadRef = useRef(loadPreview); + loadRef.current = loadPreview; + + useEffect(() => { + if (!open) return undefined; + let cancelled = false; + setPreviewLoading(true); + loadRef + .current() + .then((p) => { + if (!cancelled) setPreview(p); + }) + .catch(() => { + // Best-effort: a failed preview must not block a valid delete. + if (!cancelled) setPreview(null); + }) + .finally(() => { + if (!cancelled) setPreviewLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open]); + + const close = () => { + if (busy) return; + setOpen(false); + setErr(null); + setPreview(null); + }; + + // Block the destructive action only when the preview positively says + // so — never when it's still loading or failed to load. + const blocked = preview !== null && !preview.can_delete; + + return ( + <> + + {open && ( + + + + + } + > +

+ Are you sure you want to delete “{label}”? This + action cannot be undone. +

+ + {previewLoading && ( +

Checking what this affects…

+ )} + + {preview && preview.cascade.length > 0 && ( +
+

This will also delete:

+
    + {preview.cascade.map((c) => ( +
  • + {c.count} {c.model} +
  • + ))} +
+
+ )} + + {preview && preview.protected.length > 0 && ( +
+

Blocked — protected related objects:

+
    + {preview.protected.map((p) => ( +
  • {p}
  • + ))} +
+
+ )} + + {preview && preview.perms_needed.length > 0 && ( +
+ 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) && ( + + )} + {so?.show_save_and_continue && ( + + )} + {so?.show_save_and_add_another && ( + + )} + {so?.show_save_as_new && ( + + )} + +
+ ); + + return ( +
+ {nonFieldError && ( +
+ {nonFieldError} +
+ )} + {so?.save_on_top && renderSaveActions()} + {data.fieldsets.map((fieldset, idx) => { + // Edit mode shows only fields the operator can actually change: + // drop readonly fields from each row, then drop now-empty rows. + // A fieldset left with nothing editable is hidden entirely (no + // empty card). + const editableRows = (fieldset.field_rows ?? fieldset.fields.map((f) => [f])) + .map((row) => row.filter((name) => data.fields[name] && !data.fields[name]?.readonly)) + .filter((row) => row.length > 0); + if (editableRows.length === 0) return null; + + const renderInput = (name: string) => { + const field = data.fields[name]; + if (!field) return null; + return ( + setValues((prev) => ({ ...prev, [name]: v }))} + /> + ); + }; + + return ( + +
+ {editableRows.map((row, ri) => { + if (row.length === 1) return renderInput(row[0] as string); + // Multi-field row (#382): inputs side by side. + return ( +
+ {row.map((name) => renderInput(name))} +
+ ); + })} +
+
+ ); + })} + + {/* Editable inlines (#54 write half) — typed inputs per child row, + add/remove, submitted via the PATCH `inlines` block. */} + {editableInlines.map((inline) => ( + + + {errors[`inlines.${inline.name}`]?.map((msg, i) => ( +

+ {msg} +

+ ))} +
+ ))} + + {renderSaveActions()} + + ); +} 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 ( + + + {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 ( + + ); +} 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.'; +}