Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/<app>/<model>/<pk>/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 `/<pk>` and
`/<pk>/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
Expand Down
7 changes: 4 additions & 3 deletions frontend/apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/<pk>` 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 "<pk>/change/" vs "<pk>/change"). */}
<Route
path=":appLabel/:modelName/:pk/change"
element={<DetailPage initialEditing />}
element={<DetailPage />}
/>
<Route
path=":appLabel/:modelName/:pk/history"
Expand Down
57 changes: 57 additions & 0 deletions frontend/apps/web/src/pages/DetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ vi.mock('@dar/data', async (importOriginal) => {
...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 }),
};
});

Expand Down Expand Up @@ -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 `/<pk>/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(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/:appLabel/:modelName/:pk" element={<DetailPage />} />
{/* Mirrors App.tsx: the /change alias renders the SAME element,
no forced edit mode. */}
<Route path="/:appLabel/:modelName/:pk/change" element={<DetailPage />} />
</Routes>
</MemoryRouter>,
);
}

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 /<pk>/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();
});
});
26 changes: 8 additions & 18 deletions frontend/apps/web/src/pages/DetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `/<app>/<model>/<pk>/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
* `/<app>/<model>/<pk>/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 ?? '';
Expand All @@ -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
// `/<pk>/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);

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading