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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.9.0] — 2026-06-01

### Added

- **Change-form parity via the rest-api form-spec endpoint (#659).** The
change form is now driven by `GET <app>/<model>/<pk>/form-spec/`
(django-admin-rest-api 1.4.0+, #59) instead of discovering fields
client-side from the model serializer. The SPA now honours the
**ModelAdmin layer**: request-aware `get_form(request, obj)` /
`get_fieldsets(request, obj)` / `get_readonly_fields(request, obj)`,
`formfield_overrides`, custom `Form` classes, and the admin relation
widgets — resolved server-side and mapped through the closed
`widget.kind` enum. The original change-form querystring is forwarded,
so a `get_form` that swaps the `Form` on `?variant=…` renders the same
fields the legacy `/admin/` does. When the backend can't render the
form from JSON (a `change_form_template` override → `renderer:
"legacy-iframe"`), the SPA embeds the legacy admin page in an iframe
inside the SPA shell instead of silently dropping the customisation
(closes part of #624). A spec-fetch failure (older backend) degrades
gracefully to the previous detail-payload-driven form. New API client
method `formSpec()` + `useFormSpec` hook; the existing `FieldInput`
renders the adapted fields unchanged (one control set, no drift).

### Changed

- **Split DetailPage/ListPage into focused modules (no behavior change)
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,8 @@ issues link the work to close each gap.

| Stock-Django hook | SPA behaviour | Tracked |
|---|---|---|
| `change_form_template` / `change_list_template` / `add_form_template` / `change_password_template` / `object_history_template` overrides | Silently ignored — the SPA renders entirely from the JSON wire. | [#624](https://github.com/MartinCastroAlvarez/django-admin-react/issues/624) |
| `change_form_template` / `add_form_template` overrides | **Embedded in an iframe** (since 1.9.0, #659): the change/add form-spec endpoint returns a `legacy-iframe` pointer and the SPA embeds the legacy admin page inside the SPA shell (breadcrumb / sidebar / toolbar stay SPA-rendered). Port the form to documented ModelAdmin hooks at your own pace. | [#624](https://github.com/MartinCastroAlvarez/django-admin-react/issues/624) |
| `change_list_template` / `change_password_template` / `object_history_template` overrides | Silently ignored — those surfaces render entirely from the JSON wire. | [#624](https://github.com/MartinCastroAlvarez/django-admin-react/issues/624) |
| `formfield_overrides = {Field: {"widget": CustomWidget}}` | Custom widget invisible — the SPA picks its own control from the field's `type`. No React-side widget-registration API yet. | [#625](https://github.com/MartinCastroAlvarez/django-admin-react/issues/625) |
| `raw_id_fields` | Falls back to the autocomplete picker (same as `autocomplete_fields`). Defeats the purpose for FKs with 10M+ rows where autocomplete `get_search_results` is too expensive. | [#626](https://github.com/MartinCastroAlvarez/django-admin-react/issues/626) |
| `radio_fields = {"status": admin.HORIZONTAL}` | Renders a `<select>` (default choice control) instead of inline radio buttons. | [#626](https://github.com/MartinCastroAlvarez/django-admin-react/issues/626) |
Expand Down
15 changes: 13 additions & 2 deletions frontend/apps/web/src/pages/DetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ import { useModelMeta } from '../useModelMeta';
import { toastMessages, useToast } from '../toast';
import { followActionRedirect } from '../action-redirect';
import { carryPreservedFilters, listPathWithPreservedFilters } from '../changelistFilters';
import { ChangeForm } from './detail/ChangeForm';
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';
Expand Down Expand Up @@ -242,8 +242,19 @@ export function DetailPage({
</header>

{editing ? (
<EditForm
<ChangeForm
data={data}
appLabel={appLabel}
modelName={modelName}
pk={pk}
// Forward the original change-form querystring so a request-aware
// ModelAdmin.get_form (e.g. one branching on ?variant=…) resolves
// the matching form (#659). Strip the SPA-only `edit=1` flag.
query={(() => {
const sp = new URLSearchParams(searchParams);
sp.delete('edit');
return sp.toString();
})()}
onCancel={() => setEditing(false)}
onSave={async (payload, action) => {
// "Save as new" creates a fresh object from the current
Expand Down
130 changes: 130 additions & 0 deletions frontend/apps/web/src/pages/detail/ChangeForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import '@testing-library/jest-dom/vitest';

import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { DetailResponse, FormSpecPayload } from '@dar/data';

// Mocked SWR state the mocked useFormSpec returns, set per test.
let specState: { data: FormSpecPayload | null; loading: boolean; error: Error | null };

vi.mock('@dar/data', async (importOriginal) => {
const actual = await importOriginal<typeof import('@dar/data')>();
return {
...actual,
useApiClient: () => ({}),
useFormSpec: () => specState,
};
});

// Import AFTER the mock so ChangeForm picks up the mocked hooks.
const { ChangeForm } = await import('./ChangeForm');

function detail(): DetailResponse {
return {
app_label: 'auth',
model_name: 'group',
pk: 1,
label: 'editors',
permissions: { view: true, add: true, change: true, delete: true },
fieldsets: [{ title: null, fields: [] }],
fields: {},
inlines: [],
save_options: { show_save: true },
} as unknown as DetailResponse;
}

function renderChangeForm() {
return render(
<ChangeForm
data={detail()}
appLabel="auth"
modelName="group"
pk="1"
query=""
onCancel={() => {}}
onSave={async () => {}}
/>,
);
}

beforeEach(() => {
specState = { data: null, loading: false, error: null };
});

describe('ChangeForm (#659)', () => {
it('embeds the legacy admin in an iframe when the backend returns legacy-iframe', () => {
specState = {
data: { renderer: 'legacy-iframe', legacy_url: '/admin/auth/group/1/change/' },
loading: false,
error: null,
};
renderChangeForm();
const iframe = screen.getByTitle('Legacy admin form') as HTMLIFrameElement;
expect(iframe).toBeInTheDocument();
expect(iframe.src).toContain('/admin/auth/group/1/change/');
});

it('renders the form-spec fields (request-aware get_form / fieldsets) via EditForm', () => {
specState = {
data: {
renderer: 'form-spec',
fieldsets: [{ title: 'Identity', fields: ['name'], classes: ['collapse'] }],
fields: {
name: {
label: 'Name',
help_text: '',
required: true,
readonly: false,
type: 'string',
widget: { kind: 'text', attrs: { maxlength: 150 } },
initial: 'editors',
errors: [],
},
},
variant: 'myapp.forms.GroupForm',
},
loading: false,
error: null,
};
renderChangeForm();
const input = screen.getByLabelText('Name', { exact: false }) as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.value).toBe('editors');
expect(input).toHaveAttribute('maxlength', '150');
});

it('falls back to a default input + note for an unregistered custom widget', () => {
specState = {
data: {
renderer: 'form-spec',
fieldsets: [{ title: null, fields: ['bio'] }],
fields: {
bio: {
label: 'Bio',
help_text: '',
required: false,
readonly: false,
type: 'text',
widget: { kind: 'custom', attrs: {}, widget_class: 'mypkg.widgets.Markdown' },
initial: '',
errors: [],
},
},
variant: 'x',
},
loading: false,
error: null,
};
renderChangeForm();
expect(screen.getByText(/is not registered/i)).toBeInTheDocument();
expect(screen.getByText('mypkg.widgets.Markdown')).toBeInTheDocument();
});

it('falls back to the detail-driven form when the spec errors (older backend)', () => {
specState = { data: null, loading: false, error: new Error('404') };
// Should not throw; renders the EditForm shell (a <form> with the Save button).
renderChangeForm();
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
});
77 changes: 77 additions & 0 deletions frontend/apps/web/src/pages/detail/ChangeForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// ChangeForm — the form-spec-driven change form (#659).
//
// On edit, fetch the ModelAdmin-resolved form spec (rest-api 1.4.0+, #59)
// and render:
// - the legacy admin in an iframe, when the admin overrides
// `change_form_template` (`renderer: "legacy-iframe"`);
// - otherwise the existing EditForm, with its fields + fieldsets sourced
// from the spec (request-aware get_form / fieldsets / readonly, the
// closed widget.kind enum) instead of being discovered client-side
// from the model serializer.
//
// If the spec can't be fetched (an older backend without the endpoint, a
// transient error), we fall back to the detail-payload-driven EditForm so
// editing still works — graceful degradation, never a broken page.

import {
type DetailResponse,
useApiClient,
useFormSpec,
} from '@dar/data';

import { RecordSkeleton } from '../../components/RecordSkeleton';
import { detailFromFormSpec } from './adaptFormSpec';
import { EditForm, type SaveAction } from './EditForm';
import { LegacyIframe } from './LegacyIframe';

export interface ChangeFormProps {
data: DetailResponse;
appLabel: string;
modelName: string;
pk: string;
/** Original change-form querystring (forwarded for request-aware get_form). */
query?: string;
onCancel: () => void;
onSave: (payload: import('@dar/data').UpdatePayload, action: SaveAction) => Promise<void>;
}

export function ChangeForm({
data,
appLabel,
modelName,
pk,
query,
onCancel,
onSave,
}: ChangeFormProps) {
const client = useApiClient();
const { data: spec, loading, error } = useFormSpec({
client,
appLabel,
modelName,
pk,
// `query` is always a string from the caller (URLSearchParams.toString());
// default to '' so the optional prop is never assigned `undefined`
// (exactOptionalPropertyTypes).
query: query ?? '',
});

// First load with nothing cached → skeleton. (Background refresh is off
// for the spec, so this only shows on the very first edit-mode entry.)
if (loading && !spec) return <RecordSkeleton />;

// Spec unavailable (older backend / transient error) → fall back to the
// detail-driven form so the operator can still edit.
if (error && !spec) {
return <EditForm data={data} onCancel={onCancel} onSave={onSave} />;
}
if (!spec) return <EditForm data={data} onCancel={onCancel} onSave={onSave} />;

if (spec.renderer === 'legacy-iframe') {
return <LegacyIframe url={spec.legacy_url} onCancel={onCancel} />;
}

return (
<EditForm data={detailFromFormSpec(data, spec)} onCancel={onCancel} onSave={onSave} />
);
}
50 changes: 50 additions & 0 deletions frontend/apps/web/src/pages/detail/LegacyIframe.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// LegacyIframe — embed the legacy admin change/add page inside the SPA
// shell (#659 escape hatch).
//
// When a ModelAdmin overrides `change_form_template` / `add_form_template`,
// the form can't be faithfully rendered from the JSON form spec, so the
// backend returns `{renderer: "legacy-iframe", legacy_url}`. The breadcrumb,
// sidebar, and toolbar stay SPA-rendered; only the form body is the legacy
// page. Integrators can port the custom form to documented ModelAdmin hooks
// at their own pace without blocking SPA adoption.

import { ExternalLink } from 'lucide-react';

import { Button, Card, t } from '@dar/ui';

export interface LegacyIframeProps {
url: string;
onCancel: () => void;
}

export function LegacyIframe({ url, onCancel }: LegacyIframeProps) {
return (
<Card>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-gray-500">
{t('This form is rendered by the legacy admin (custom change_form_template).')}
</p>
<div className="flex shrink-0 items-center gap-2">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<ExternalLink className="h-4 w-4" aria-hidden /> {t('Open in new tab')}
</a>
<Button type="button" variant="ghost" onClick={onCancel}>
{t('Cancel')}
</Button>
</div>
</div>
<iframe
src={url}
title={t('Legacy admin form')}
className="h-[70vh] w-full rounded border border-gray-200 bg-white"
/>
</div>
</Card>
);
}
Loading
Loading