Skip to content

Commit 1b33019

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
feat(spa): detail page opens read-only details by default (#682) + 1.13.0 (#683)
Visiting a record — including the Django-admin `/<pk>/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 `<DetailPage />` as `/<pk>`); 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: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 833bf4b commit 1b33019

5 files changed

Lines changed: 86 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.13.0] — 2026-06-03
11+
12+
### Changed
13+
- **The detail page now opens in the read-only DETAILS view by default,
14+
including on the Django-admin `/<app>/<model>/<pk>/change/` URL alias
15+
(#682).** Previously `/change/` forced edit mode, so a shared record link
16+
dropped recipients into an editable form (with empty inline "add" rows) —
17+
one stray keystroke from an accidental save. Now both `/<pk>` and
18+
`/<pk>/change/` render the same read-back view (FK/M2M as linked labels,
19+
choices as their display label, inlines as read-only tables); the toolbar
20+
**Edit** button flips the page into edit mode in place (no URL change), and
21+
`?edit=1` still deep-links straight to edit (and lands the "Save and
22+
continue editing" round-trip there). View-only users never see the Edit
23+
button. The add form (`/add/`) is unaffected — it still opens ready to fill
24+
in. No backend / form-spec contract change.
25+
1026
## [1.12.0] — 2026-06-02
1127

1228
### Added

frontend/apps/web/src/App.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,14 @@ export function App() {
8585
at the legacy admin's prefix (after a /admin/ ↔ /admin-old/
8686
swap), bookmarked + copy-pasted legacy URLs land here.
8787
Treat each as an equivalent match — same DetailPage
88-
component, just opened with the right initial mode /
89-
panel so the user lands where the link said they would.
88+
component. `/change/` opens the read-only DETAILS view by
89+
default (#682), same as the bare `/<pk>` route; edit mode
90+
is one Edit-button click away, or a `?edit=1` deep link.
9091
Trailing slashes are normalised by React Router v6 (no
9192
extra route needed for "<pk>/change/" vs "<pk>/change"). */}
9293
<Route
9394
path=":appLabel/:modelName/:pk/change"
94-
element={<DetailPage initialEditing />}
95+
element={<DetailPage />}
9596
/>
9697
<Route
9798
path=":appLabel/:modelName/:pk/history"

frontend/apps/web/src/pages/DetailPage.test.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ vi.mock('@dar/data', async (importOriginal) => {
6161
...actual,
6262
useApiClient: () => ({}),
6363
useDetail: () => ({ data: detailState, loading: false, error: null, refresh: async () => {} }),
64+
// Edit mode: no live form-spec → ChangeForm falls back to the
65+
// detail-payload-driven EditForm, which renders deterministically
66+
// (a Save row) without a network fetch.
67+
useFormSpec: () => ({ data: null, loading: false, error: null }),
6468
};
6569
});
6670

@@ -202,3 +206,56 @@ describe('DetailPage many-actions toolbar (#672 regression guard)', () => {
202206
expect(longest.className).toContain('break-words');
203207
});
204208
});
209+
210+
describe('DetailPage details/edit mode default (#682)', () => {
211+
// #682: a record page — including the Django-admin `/<pk>/change/` URL
212+
// alias — opens the READ-ONLY details view by default. A shared link is
213+
// safe to open; edit mode is one Edit-button click (in place) or a
214+
// `?edit=1` deep link away. Read mode shows the toolbar (History + Edit)
215+
// and no Save; edit mode hides the toolbar entirely and shows Save.
216+
function renderAt(entry: string) {
217+
return render(
218+
<MemoryRouter initialEntries={[entry]}>
219+
<Routes>
220+
<Route path="/:appLabel/:modelName/:pk" element={<DetailPage />} />
221+
{/* Mirrors App.tsx: the /change alias renders the SAME element,
222+
no forced edit mode. */}
223+
<Route path="/:appLabel/:modelName/:pk/change" element={<DetailPage />} />
224+
</Routes>
225+
</MemoryRouter>,
226+
);
227+
}
228+
229+
it('opens read-only details mode by default — no Save, toolbar Edit present', () => {
230+
renderAt('/auth/group/1');
231+
expect(screen.getByRole('button', { name: /history/i })).toBeInTheDocument();
232+
expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument();
233+
expect(screen.queryByRole('button', { name: /save/i })).toBeNull();
234+
});
235+
236+
it('opens read-only details mode on the /<pk>/change/ alias too', () => {
237+
renderAt('/auth/group/1/change');
238+
expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument();
239+
expect(screen.queryByRole('button', { name: /save/i })).toBeNull();
240+
});
241+
242+
it('deep-links straight to edit mode with ?edit=1 (toolbar hidden, Save shown)', () => {
243+
renderAt('/auth/group/1?edit=1');
244+
// Edit mode hides the read-mode toolbar entirely…
245+
expect(screen.queryByRole('button', { name: /history/i })).toBeNull();
246+
expect(screen.queryByRole('button', { name: /^edit$/i })).toBeNull();
247+
// …and the form's Save row is present.
248+
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
249+
});
250+
251+
it('hides the Edit button for view-only users (details mode only)', () => {
252+
detailState = detail({
253+
permissions: { view: true, add: false, change: false, delete: false },
254+
});
255+
renderAt('/auth/group/1');
256+
// Still read mode (toolbar present) but no Edit affordance → can't mutate.
257+
expect(screen.getByRole('button', { name: /history/i })).toBeInTheDocument();
258+
expect(screen.queryByRole('button', { name: /^edit$/i })).toBeNull();
259+
expect(screen.queryByRole('button', { name: /save/i })).toBeNull();
260+
});
261+
});

frontend/apps/web/src/pages/DetailPage.tsx

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,21 +44,13 @@ import { InlineSection } from './detail/InlineSection';
4444
import { ObjectActionButton } from './detail/ObjectActionButton';
4545

4646
export interface DetailPageProps {
47-
/** Open the page directly in edit mode (Django-admin URL alias
48-
* `/<app>/<model>/<pk>/change/`, #601). Also still triggered by
49-
* the existing `?edit=1` query param from "Save and continue
50-
* editing" (#154). */
51-
initialEditing?: boolean;
5247
/** Open the History modal on first paint (Django-admin URL alias
5348
* `/<app>/<model>/<pk>/history/`, #601). The user can still close
5449
* it; this just sets the initial state. */
5550
initialHistoryOpen?: boolean;
5651
}
5752

58-
export function DetailPage({
59-
initialEditing = false,
60-
initialHistoryOpen = false,
61-
}: DetailPageProps = {}) {
53+
export function DetailPage({ initialHistoryOpen = false }: DetailPageProps = {}) {
6254
const params = useParams<{ appLabel: string; modelName: string; pk: string }>();
6355
const appLabel = params.appLabel ?? '';
6456
const modelName = params.modelName ?? '';
@@ -69,15 +61,13 @@ export function DetailPage({
6961
const [searchParams] = useSearchParams();
7062
const { data, loading, error, refresh } = useDetail({ client, appLabel, modelName, pk });
7163

72-
// Open straight in edit mode when arriving via "Save and continue
73-
// editing" from the add form (`?edit=1`); otherwise start read-only.
74-
// Initial mode is the OR of (a) the Django-admin URL alias the router
75-
// matched and (b) the existing `?edit=1` "Save and continue editing"
76-
// round-trip — either drops the user in edit mode on first paint
77-
// (#154, #601).
78-
const [editing, setEditing] = useState(
79-
() => initialEditing || searchParams.get('edit') === '1',
80-
);
64+
// Default to the read-only DETAILS view — even on the Django-admin
65+
// `/<pk>/change/` URL alias (#682). A shared link should be safe to
66+
// open; the viewer reads the record first and clicks Edit to mutate.
67+
// Edit mode is reached only via the toolbar Edit button (in-place, no
68+
// URL change) or a `?edit=1` deep link — the latter also lands the
69+
// "Save and continue editing" round-trip back in edit mode (#154).
70+
const [editing, setEditing] = useState(() => searchParams.get('edit') === '1');
8171
const [historyOpen, setHistoryOpen] = useState(initialHistoryOpen);
8272
const { plural: modelPlural } = useModelMeta(appLabel, modelName);
8373

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-admin-react"
3-
version = "1.12.0"
3+
version = "1.13.0"
44
description = "A drop-in React single-page admin for Django, driven entirely by ModelAdmin."
55
authors = ["django-admin-react contributors"]
66
license = "MIT"

0 commit comments

Comments
 (0)