Skip to content

Commit ccc81db

Browse files
feat(spa): form-spec-driven change form + legacy-iframe fallback (#659) + 1.9.0
The change form is now driven by the rest-api form-spec endpoint (GET <app>/<model>/<pk>/form-spec/, django-admin-rest-api 1.4.0+ #59) instead of discovering fields client-side from the model serializer, so the SPA honours the ModelAdmin layer: request-aware get_form / get_fieldsets / get_readonly_fields, 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 legacy admin's fields. - A change_form_template / add_form_template override returns renderer: "legacy-iframe"; the SPA embeds the legacy admin page in an iframe inside the SPA shell (closes part of #624) rather than silently dropping the customisation. - A spec-fetch failure (older backend) degrades gracefully to the previous detail-payload-driven form. Implementation reuses the battle-tested FieldInput/EditForm: a small adapter maps each FormSpecField onto the existing FieldDescriptor (the backend reuses the detail serializer for `initial`, so value shapes line up). New: FormSpecResponse/WidgetKind wire types, client.formSpec(), useFormSpec hook, adaptFormSpec, LegacyIframe, ChangeForm. i18n strings for es/fr/pt. 11 new tests (adapter mapping + ChangeForm branches: iframe, form-spec fields, custom-widget fallback, graceful degradation). Raises the django-admin-rest-api floor to ^1.4.0 (the endpoint) and the django-admin-mcp-api floor to >=1.2.0 (the parity tool, #70). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a73177a commit ccc81db

16 files changed

Lines changed: 668 additions & 9 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

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

1235
- **Split DetailPage/ListPage into focused modules (no behavior change)

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,8 @@ issues link the work to close each gap.
646646

647647
| Stock-Django hook | SPA behaviour | Tracked |
648648
|---|---|---|
649-
| `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) |
649+
| `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) |
650+
| `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) |
650651
| `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) |
651652
| `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) |
652653
| `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) |

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ import { useModelMeta } from '../useModelMeta';
3636
import { toastMessages, useToast } from '../toast';
3737
import { followActionRedirect } from '../action-redirect';
3838
import { carryPreservedFilters, listPathWithPreservedFilters } from '../changelistFilters';
39+
import { ChangeForm } from './detail/ChangeForm';
3940
import { CustomViewsMenu } from './detail/CustomViewsMenu';
4041
import { DeleteButton } from './detail/DeleteButton';
41-
import { EditForm } from './detail/EditForm';
4242
import { FieldsetSection } from './detail/FieldsetSection';
4343
import { InlineSection } from './detail/InlineSection';
4444
import { ObjectActionButton } from './detail/ObjectActionButton';
@@ -242,8 +242,19 @@ export function DetailPage({
242242
</header>
243243

244244
{editing ? (
245-
<EditForm
245+
<ChangeForm
246246
data={data}
247+
appLabel={appLabel}
248+
modelName={modelName}
249+
pk={pk}
250+
// Forward the original change-form querystring so a request-aware
251+
// ModelAdmin.get_form (e.g. one branching on ?variant=…) resolves
252+
// the matching form (#659). Strip the SPA-only `edit=1` flag.
253+
query={(() => {
254+
const sp = new URLSearchParams(searchParams);
255+
sp.delete('edit');
256+
return sp.toString();
257+
})()}
247258
onCancel={() => setEditing(false)}
248259
onSave={async (payload, action) => {
249260
// "Save as new" creates a fresh object from the current
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import '@testing-library/jest-dom/vitest';
2+
3+
import { render, screen } from '@testing-library/react';
4+
import { beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
import type { DetailResponse, FormSpecPayload } from '@dar/data';
7+
8+
// Mocked SWR state the mocked useFormSpec returns, set per test.
9+
let specState: { data: FormSpecPayload | null; loading: boolean; error: Error | null };
10+
11+
vi.mock('@dar/data', async (importOriginal) => {
12+
const actual = await importOriginal<typeof import('@dar/data')>();
13+
return {
14+
...actual,
15+
useApiClient: () => ({}),
16+
useFormSpec: () => specState,
17+
};
18+
});
19+
20+
// Import AFTER the mock so ChangeForm picks up the mocked hooks.
21+
const { ChangeForm } = await import('./ChangeForm');
22+
23+
function detail(): DetailResponse {
24+
return {
25+
app_label: 'auth',
26+
model_name: 'group',
27+
pk: 1,
28+
label: 'editors',
29+
permissions: { view: true, add: true, change: true, delete: true },
30+
fieldsets: [{ title: null, fields: [] }],
31+
fields: {},
32+
inlines: [],
33+
save_options: { show_save: true },
34+
} as unknown as DetailResponse;
35+
}
36+
37+
function renderChangeForm() {
38+
return render(
39+
<ChangeForm
40+
data={detail()}
41+
appLabel="auth"
42+
modelName="group"
43+
pk="1"
44+
query=""
45+
onCancel={() => {}}
46+
onSave={async () => {}}
47+
/>,
48+
);
49+
}
50+
51+
beforeEach(() => {
52+
specState = { data: null, loading: false, error: null };
53+
});
54+
55+
describe('ChangeForm (#659)', () => {
56+
it('embeds the legacy admin in an iframe when the backend returns legacy-iframe', () => {
57+
specState = {
58+
data: { renderer: 'legacy-iframe', legacy_url: '/admin/auth/group/1/change/' },
59+
loading: false,
60+
error: null,
61+
};
62+
renderChangeForm();
63+
const iframe = screen.getByTitle('Legacy admin form') as HTMLIFrameElement;
64+
expect(iframe).toBeInTheDocument();
65+
expect(iframe.src).toContain('/admin/auth/group/1/change/');
66+
});
67+
68+
it('renders the form-spec fields (request-aware get_form / fieldsets) via EditForm', () => {
69+
specState = {
70+
data: {
71+
renderer: 'form-spec',
72+
fieldsets: [{ title: 'Identity', fields: ['name'], classes: ['collapse'] }],
73+
fields: {
74+
name: {
75+
label: 'Name',
76+
help_text: '',
77+
required: true,
78+
readonly: false,
79+
type: 'string',
80+
widget: { kind: 'text', attrs: { maxlength: 150 } },
81+
initial: 'editors',
82+
errors: [],
83+
},
84+
},
85+
variant: 'myapp.forms.GroupForm',
86+
},
87+
loading: false,
88+
error: null,
89+
};
90+
renderChangeForm();
91+
const input = screen.getByLabelText('Name', { exact: false }) as HTMLInputElement;
92+
expect(input).toBeInTheDocument();
93+
expect(input.value).toBe('editors');
94+
expect(input).toHaveAttribute('maxlength', '150');
95+
});
96+
97+
it('falls back to a default input + note for an unregistered custom widget', () => {
98+
specState = {
99+
data: {
100+
renderer: 'form-spec',
101+
fieldsets: [{ title: null, fields: ['bio'] }],
102+
fields: {
103+
bio: {
104+
label: 'Bio',
105+
help_text: '',
106+
required: false,
107+
readonly: false,
108+
type: 'text',
109+
widget: { kind: 'custom', attrs: {}, widget_class: 'mypkg.widgets.Markdown' },
110+
initial: '',
111+
errors: [],
112+
},
113+
},
114+
variant: 'x',
115+
},
116+
loading: false,
117+
error: null,
118+
};
119+
renderChangeForm();
120+
expect(screen.getByText(/is not registered/i)).toBeInTheDocument();
121+
expect(screen.getByText('mypkg.widgets.Markdown')).toBeInTheDocument();
122+
});
123+
124+
it('falls back to the detail-driven form when the spec errors (older backend)', () => {
125+
specState = { data: null, loading: false, error: new Error('404') };
126+
// Should not throw; renders the EditForm shell (a <form> with the Save button).
127+
renderChangeForm();
128+
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
129+
});
130+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// ChangeForm — the form-spec-driven change form (#659).
2+
//
3+
// On edit, fetch the ModelAdmin-resolved form spec (rest-api 1.4.0+, #59)
4+
// and render:
5+
// - the legacy admin in an iframe, when the admin overrides
6+
// `change_form_template` (`renderer: "legacy-iframe"`);
7+
// - otherwise the existing EditForm, with its fields + fieldsets sourced
8+
// from the spec (request-aware get_form / fieldsets / readonly, the
9+
// closed widget.kind enum) instead of being discovered client-side
10+
// from the model serializer.
11+
//
12+
// If the spec can't be fetched (an older backend without the endpoint, a
13+
// transient error), we fall back to the detail-payload-driven EditForm so
14+
// editing still works — graceful degradation, never a broken page.
15+
16+
import {
17+
type DetailResponse,
18+
useApiClient,
19+
useFormSpec,
20+
} from '@dar/data';
21+
22+
import { RecordSkeleton } from '../../components/RecordSkeleton';
23+
import { detailFromFormSpec } from './adaptFormSpec';
24+
import { EditForm, type SaveAction } from './EditForm';
25+
import { LegacyIframe } from './LegacyIframe';
26+
27+
export interface ChangeFormProps {
28+
data: DetailResponse;
29+
appLabel: string;
30+
modelName: string;
31+
pk: string;
32+
/** Original change-form querystring (forwarded for request-aware get_form). */
33+
query?: string;
34+
onCancel: () => void;
35+
onSave: (payload: import('@dar/data').UpdatePayload, action: SaveAction) => Promise<void>;
36+
}
37+
38+
export function ChangeForm({
39+
data,
40+
appLabel,
41+
modelName,
42+
pk,
43+
query,
44+
onCancel,
45+
onSave,
46+
}: ChangeFormProps) {
47+
const client = useApiClient();
48+
const { data: spec, loading, error } = useFormSpec({
49+
client,
50+
appLabel,
51+
modelName,
52+
pk,
53+
// `query` is always a string from the caller (URLSearchParams.toString());
54+
// default to '' so the optional prop is never assigned `undefined`
55+
// (exactOptionalPropertyTypes).
56+
query: query ?? '',
57+
});
58+
59+
// First load with nothing cached → skeleton. (Background refresh is off
60+
// for the spec, so this only shows on the very first edit-mode entry.)
61+
if (loading && !spec) return <RecordSkeleton />;
62+
63+
// Spec unavailable (older backend / transient error) → fall back to the
64+
// detail-driven form so the operator can still edit.
65+
if (error && !spec) {
66+
return <EditForm data={data} onCancel={onCancel} onSave={onSave} />;
67+
}
68+
if (!spec) return <EditForm data={data} onCancel={onCancel} onSave={onSave} />;
69+
70+
if (spec.renderer === 'legacy-iframe') {
71+
return <LegacyIframe url={spec.legacy_url} onCancel={onCancel} />;
72+
}
73+
74+
return (
75+
<EditForm data={detailFromFormSpec(data, spec)} onCancel={onCancel} onSave={onSave} />
76+
);
77+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// LegacyIframe — embed the legacy admin change/add page inside the SPA
2+
// shell (#659 escape hatch).
3+
//
4+
// When a ModelAdmin overrides `change_form_template` / `add_form_template`,
5+
// the form can't be faithfully rendered from the JSON form spec, so the
6+
// backend returns `{renderer: "legacy-iframe", legacy_url}`. The breadcrumb,
7+
// sidebar, and toolbar stay SPA-rendered; only the form body is the legacy
8+
// page. Integrators can port the custom form to documented ModelAdmin hooks
9+
// at their own pace without blocking SPA adoption.
10+
11+
import { ExternalLink } from 'lucide-react';
12+
13+
import { Button, Card, t } from '@dar/ui';
14+
15+
export interface LegacyIframeProps {
16+
url: string;
17+
onCancel: () => void;
18+
}
19+
20+
export function LegacyIframe({ url, onCancel }: LegacyIframeProps) {
21+
return (
22+
<Card>
23+
<div className="space-y-3">
24+
<div className="flex flex-wrap items-center justify-between gap-2">
25+
<p className="text-xs text-gray-500">
26+
{t('This form is rendered by the legacy admin (custom change_form_template).')}
27+
</p>
28+
<div className="flex shrink-0 items-center gap-2">
29+
<a
30+
href={url}
31+
target="_blank"
32+
rel="noopener noreferrer"
33+
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"
34+
>
35+
<ExternalLink className="h-4 w-4" aria-hidden /> {t('Open in new tab')}
36+
</a>
37+
<Button type="button" variant="ghost" onClick={onCancel}>
38+
{t('Cancel')}
39+
</Button>
40+
</div>
41+
</div>
42+
<iframe
43+
src={url}
44+
title={t('Legacy admin form')}
45+
className="h-[70vh] w-full rounded border border-gray-200 bg-white"
46+
/>
47+
</div>
48+
</Card>
49+
);
50+
}

0 commit comments

Comments
 (0)