From f494057be3ebd70cf727afd5972e5bf386eb0010 Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs Date: Wed, 3 Jun 2026 01:02:25 +0200 Subject: [PATCH] feat(spa): detail page opens read-only details by default (#682) + 1.13.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visiting a record — including the Django-admin `//change/` URL alias — now opens the read-only DETAILS view instead of the edit form. A shared link is safe to open: the viewer reads the record (FK/M2M as linked labels, choices as display labels, inlines as read-only tables) and clicks the toolbar Edit button to flip into edit mode in place. `?edit=1` still deep-links straight to edit and lands the "Save and continue editing" round-trip there; view-only users never see Edit. The add form is unaffected. Implementation: drop the route-forced `initialEditing` (the `/change` route now renders the same `` as `/`); edit mode derives solely from `?edit=1`. The read/edit rendering split, FK-link/choice rendering, and read-only inlines already existed — only the default mode changed. Adds DetailPage tests for read-default, the `/change` alias, `?edit=1`, and the view-only case. No backend / form-spec change. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 16 ++++++ frontend/apps/web/src/App.tsx | 7 ++- .../apps/web/src/pages/DetailPage.test.tsx | 57 +++++++++++++++++++ frontend/apps/web/src/pages/DetailPage.tsx | 26 +++------ pyproject.toml | 2 +- 5 files changed, 86 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ffa5f9..9fe80f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.13.0] — 2026-06-03 + +### Changed +- **The detail page now opens in the read-only DETAILS view by default, + including on the Django-admin `////change/` URL alias + (#682).** Previously `/change/` forced edit mode, so a shared record link + dropped recipients into an editable form (with empty inline "add" rows) — + one stray keystroke from an accidental save. Now both `/` and + `//change/` render the same read-back view (FK/M2M as linked labels, + choices as their display label, inlines as read-only tables); the toolbar + **Edit** button flips the page into edit mode in place (no URL change), and + `?edit=1` still deep-links straight to edit (and lands the "Save and + continue editing" round-trip there). View-only users never see the Edit + button. The add form (`/add/`) is unaffected — it still opens ready to fill + in. No backend / form-spec contract change. + ## [1.12.0] — 2026-06-02 ### Added diff --git a/frontend/apps/web/src/App.tsx b/frontend/apps/web/src/App.tsx index cea41d5..105a2a1 100644 --- a/frontend/apps/web/src/App.tsx +++ b/frontend/apps/web/src/App.tsx @@ -85,13 +85,14 @@ export function App() { at the legacy admin's prefix (after a /admin/ ↔ /admin-old/ swap), bookmarked + copy-pasted legacy URLs land here. Treat each as an equivalent match — same DetailPage - component, just opened with the right initial mode / - panel so the user lands where the link said they would. + component. `/change/` opens the read-only DETAILS view by + default (#682), same as the bare `/` route; edit mode + is one Edit-button click away, or a `?edit=1` deep link. Trailing slashes are normalised by React Router v6 (no extra route needed for "/change/" vs "/change"). */} } + element={} /> { ...actual, useApiClient: () => ({}), useDetail: () => ({ data: detailState, loading: false, error: null, refresh: async () => {} }), + // Edit mode: no live form-spec → ChangeForm falls back to the + // detail-payload-driven EditForm, which renders deterministically + // (a Save row) without a network fetch. + useFormSpec: () => ({ data: null, loading: false, error: null }), }; }); @@ -202,3 +206,56 @@ describe('DetailPage many-actions toolbar (#672 regression guard)', () => { expect(longest.className).toContain('break-words'); }); }); + +describe('DetailPage details/edit mode default (#682)', () => { + // #682: a record page — including the Django-admin `//change/` URL + // alias — opens the READ-ONLY details view by default. A shared link is + // safe to open; edit mode is one Edit-button click (in place) or a + // `?edit=1` deep link away. Read mode shows the toolbar (History + Edit) + // and no Save; edit mode hides the toolbar entirely and shows Save. + function renderAt(entry: string) { + return render( + + + } /> + {/* Mirrors App.tsx: the /change alias renders the SAME element, + no forced edit mode. */} + } /> + + , + ); + } + + it('opens read-only details mode by default — no Save, toolbar Edit present', () => { + renderAt('/auth/group/1'); + expect(screen.getByRole('button', { name: /history/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /save/i })).toBeNull(); + }); + + it('opens read-only details mode on the //change/ alias too', () => { + renderAt('/auth/group/1/change'); + expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /save/i })).toBeNull(); + }); + + it('deep-links straight to edit mode with ?edit=1 (toolbar hidden, Save shown)', () => { + renderAt('/auth/group/1?edit=1'); + // Edit mode hides the read-mode toolbar entirely… + expect(screen.queryByRole('button', { name: /history/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /^edit$/i })).toBeNull(); + // …and the form's Save row is present. + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + + it('hides the Edit button for view-only users (details mode only)', () => { + detailState = detail({ + permissions: { view: true, add: false, change: false, delete: false }, + }); + renderAt('/auth/group/1'); + // Still read mode (toolbar present) but no Edit affordance → can't mutate. + expect(screen.getByRole('button', { name: /history/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^edit$/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /save/i })).toBeNull(); + }); +}); diff --git a/frontend/apps/web/src/pages/DetailPage.tsx b/frontend/apps/web/src/pages/DetailPage.tsx index 0756ea2..b3ae588 100644 --- a/frontend/apps/web/src/pages/DetailPage.tsx +++ b/frontend/apps/web/src/pages/DetailPage.tsx @@ -44,21 +44,13 @@ import { InlineSection } from './detail/InlineSection'; import { ObjectActionButton } from './detail/ObjectActionButton'; export interface DetailPageProps { - /** Open the page directly in edit mode (Django-admin URL alias - * `////change/`, #601). Also still triggered by - * the existing `?edit=1` query param from "Save and continue - * editing" (#154). */ - initialEditing?: boolean; /** Open the History modal on first paint (Django-admin URL alias * `////history/`, #601). The user can still close * it; this just sets the initial state. */ initialHistoryOpen?: boolean; } -export function DetailPage({ - initialEditing = false, - initialHistoryOpen = false, -}: DetailPageProps = {}) { +export function DetailPage({ initialHistoryOpen = false }: DetailPageProps = {}) { const params = useParams<{ appLabel: string; modelName: string; pk: string }>(); const appLabel = params.appLabel ?? ''; const modelName = params.modelName ?? ''; @@ -69,15 +61,13 @@ export function DetailPage({ const [searchParams] = useSearchParams(); const { data, loading, error, refresh } = useDetail({ client, appLabel, modelName, pk }); - // Open straight in edit mode when arriving via "Save and continue - // editing" from the add form (`?edit=1`); otherwise start read-only. - // Initial mode is the OR of (a) the Django-admin URL alias the router - // matched and (b) the existing `?edit=1` "Save and continue editing" - // round-trip — either drops the user in edit mode on first paint - // (#154, #601). - const [editing, setEditing] = useState( - () => initialEditing || searchParams.get('edit') === '1', - ); + // Default to the read-only DETAILS view — even on the Django-admin + // `//change/` URL alias (#682). A shared link should be safe to + // open; the viewer reads the record first and clicks Edit to mutate. + // Edit mode is reached only via the toolbar Edit button (in-place, no + // URL change) or a `?edit=1` deep link — the latter also lands the + // "Save and continue editing" round-trip back in edit mode (#154). + const [editing, setEditing] = useState(() => searchParams.get('edit') === '1'); const [historyOpen, setHistoryOpen] = useState(initialHistoryOpen); const { plural: modelPlural } = useModelMeta(appLabel, modelName); diff --git a/pyproject.toml b/pyproject.toml index 95d5e80..b4387a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-admin-react" -version = "1.12.0" +version = "1.13.0" description = "A drop-in React single-page admin for Django, driven entirely by ModelAdmin." authors = ["django-admin-react contributors"] license = "MIT"