Skip to content

Commit 2619839

Browse files
feat(spa): create — Add form completes CRUD
The SPA could read/edit/delete but not create. This adds the create path so a new object can be added entirely from /admin2/, completing core CRUD parity with the Django admin. Backend: - views/create_form.py — GET <app>/<model>/add/ returns the create-form schema (fieldsets + field descriptors) for a NEW object. Builds an unsaved instance + the ADD form (get_form(request, obj=None, change=False) — exactly how Django's add view constructs it) and reuses the detail view's descriptor builders so the field shape is byte-identical to edit (one FieldInput renders both). Gated on has_add_permission (create is gated on add, not view). Sensitive-name denylist applied. No pk/label/inlines (it's a new object). urls.py: `add/` route before the `<pk>` instance route. Frontend: - AddFormResponse contract type + ApiClient.addForm(). - CreatePage.tsx — fetches the add-form schema, renders the shared FieldInput form, POSTs via createObject, surfaces field-level validation errors, navigates to the new object's detail on success. - App.tsx — `:app/:model/add` route (literal `add` ranks above the `:pk` route in React Router, so it can't be mistaken for a detail). - ListPage — "+ Add <Model>" button in the header, shown only when permissions.add. Create goes through ModelAdmin.get_form() → is_valid() → save_model() on the backend (the existing POST path); the SPA only builds the payload + renders errors. Tests (tests/test_create_form.py): anon→redirect/403, non-staff→403, staff+add→200 with field descriptors, staff without add perm→403, unregistered→404, and the add form is built with change=False/obj=None (Django add-view contract). 22/22 create-form + detail tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2f5cad0 commit 2619839

9 files changed

Lines changed: 392 additions & 11 deletions

File tree

django_admin_react/api/urls.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from django_admin_react.api.views.autocomplete import AutocompleteView
2727
from django_admin_react.api.views.bulk import BulkUpdateView
2828
from django_admin_react.api.views.create import CreateView
29+
from django_admin_react.api.views.create_form import AddFormView
2930
from django_admin_react.api.views.delete_preview import DeletePreviewView
3031
from django_admin_react.api.views.destroy import DestroyView
3132
from django_admin_react.api.views.detail import DetailView
@@ -110,6 +111,14 @@ def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpRespons
110111
BulkUpdateView.as_view(),
111112
name="bulk_update",
112113
),
114+
# Add-form schema — the create page's field descriptors for a NEW
115+
# object. Literal ``add`` must precede the ``<pk>`` instance route
116+
# below so it isn't swallowed as a pk.
117+
path(
118+
"<str:app_label>/<str:model_name>/add/",
119+
AddFormView.as_view(),
120+
name="add_form",
121+
),
113122
path(
114123
"<str:app_label>/<str:model_name>/",
115124
CollectionView.as_view(),
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""``GET /api/v1/<app>/<model>/add/`` — the create-form schema.
2+
3+
The detail view (``/<pk>/``) needs an existing object; the SPA's
4+
create page needs the same field descriptors + fieldsets for a *new*
5+
object. This view builds that payload from an unsaved instance, the
6+
add form (``get_form(request, obj=None, change=False)`` — exactly how
7+
Django's add view builds it), and the read-visible field set.
8+
9+
It deliberately reuses the detail view's descriptor builders so the
10+
field shape is byte-for-byte identical to what edit renders — the SPA
11+
uses one ``FieldInput`` component for both.
12+
13+
Hard rules: staff gate (rule 1), model resolved through the registry
14+
(rule 3), ``has_add_permission`` gate (rule 6 — create is gated on
15+
add, not view), sensitive-name denylist applied (S-31).
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from typing import Any
21+
22+
from django.http import HttpRequest
23+
from django.http import HttpResponse
24+
from django.http import JsonResponse
25+
from django.views.generic import View
26+
27+
from django_admin_react.api.permissions import forbidden_response
28+
from django_admin_react.api.permissions import is_admin_user
29+
from django_admin_react.api.registry import get_admin_site
30+
from django_admin_react.api.registry import model_permissions
31+
from django_admin_react.api.registry import resolve_model
32+
from django_admin_react.api.views.detail import _descriptor_for
33+
from django_admin_react.api.views.detail import _fieldsets_payload
34+
from django_admin_react.api.views.detail import _visible_field_names
35+
from django_admin_react.api.writes import not_found_response
36+
37+
38+
class AddFormView(View):
39+
"""``GET /api/v1/<app_label>/<model_name>/add/`` — empty create form."""
40+
41+
http_method_names = ["get"]
42+
43+
def get(
44+
self,
45+
request: HttpRequest,
46+
app_label: str,
47+
model_name: str,
48+
*args: Any,
49+
**kwargs: Any,
50+
) -> HttpResponse:
51+
admin_site = get_admin_site()
52+
if not is_admin_user(request, admin_site=admin_site):
53+
return forbidden_response(request)
54+
55+
resolved = resolve_model(admin_site, request, app_label, model_name)
56+
if resolved is None:
57+
return not_found_response()
58+
model, model_admin = resolved
59+
60+
# Create is gated on add — not view. A user who can view but
61+
# not add must not be handed an add form.
62+
if not model_admin.has_add_permission(request):
63+
return forbidden_response(request)
64+
65+
# Unsaved instance so descriptor builders have field defaults to
66+
# read (FK → None, M2M → [] via the guards in _descriptor_for).
67+
obj = model()
68+
69+
visible_names = _visible_field_names(model_admin, request, None)
70+
readonly = set(model_admin.get_readonly_fields(request, None) or ())
71+
# The ADD form — change=False, obj=None — exactly how Django's
72+
# add view constructs it (``ModelAdmin._changeform_view`` with
73+
# add=True passes change=False).
74+
form = model_admin.get_form(request, obj=None, change=False)()
75+
76+
fields: dict[str, dict[str, Any]] = {}
77+
for name in visible_names:
78+
fields[name] = _descriptor_for(
79+
model=model,
80+
model_admin=model_admin,
81+
obj=obj,
82+
name=name,
83+
form=form,
84+
is_readonly=name in readonly,
85+
)
86+
87+
payload = {
88+
"app_label": model._meta.app_label,
89+
"model_name": model._meta.model_name,
90+
"permissions": model_permissions(model_admin, request),
91+
"fieldsets": _fieldsets_payload(model_admin, request, None, visible_names),
92+
"fields": fields,
93+
}
94+
response = JsonResponse(payload, status=200)
95+
response["Cache-Control"] = "no-store"
96+
return response

frontend/apps/web/src/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { HomePage } from './pages/HomePage';
77
import { ListPage } from './pages/ListPage';
88
import { DetailPage } from './pages/DetailPage';
99
import { LoginPage } from './pages/LoginPage';
10+
import { CreatePage } from './pages/CreatePage';
1011

1112
export function App() {
1213
const registry = useRegistry();
@@ -30,6 +31,10 @@ export function App() {
3031
<Routes>
3132
<Route path="/" element={<HomePage />} />
3233
<Route path=":appLabel/:modelName" element={<ListPage />} />
34+
{/* Literal `add` is ranked above the `:pk` route by React
35+
Router, so /app/model/add opens the create form, not a
36+
detail with pk="add". */}
37+
<Route path=":appLabel/:modelName/add" element={<CreatePage />} />
3338
<Route path=":appLabel/:modelName/:pk" element={<DetailPage />} />
3439
<Route
3540
path="*"
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// CreatePage — add a new object.
2+
//
3+
// Fetches the create-form schema from GET <app>/<model>/add/ (same
4+
// field/fieldset shape as detail, for an unsaved object), renders the
5+
// shared FieldInput form, and POSTs via createObject. Field-level
6+
// validation errors come back in the envelope and render next to each
7+
// input. On success, navigates to the new object's detail page.
8+
9+
import { useEffect, useState } from 'react';
10+
import { Link, useNavigate, useParams } from 'react-router-dom';
11+
12+
import {
13+
ApiError,
14+
createObject,
15+
useApiClient,
16+
type AddFormResponse,
17+
type WriteValue,
18+
} from '@dar/data';
19+
import { Button, Card, EmptyState, Spinner } from '@dar/ui';
20+
21+
import { FieldInput } from '../components/FieldInput';
22+
23+
export function CreatePage() {
24+
const params = useParams<{ appLabel: string; modelName: string }>();
25+
const appLabel = params.appLabel ?? '';
26+
const modelName = params.modelName ?? '';
27+
const client = useApiClient();
28+
const navigate = useNavigate();
29+
30+
const [schema, setSchema] = useState<AddFormResponse | null>(null);
31+
const [loadError, setLoadError] = useState<string | null>(null);
32+
33+
useEffect(() => {
34+
let alive = true;
35+
setSchema(null);
36+
setLoadError(null);
37+
client
38+
.addForm(appLabel, modelName)
39+
.then((s) => {
40+
if (alive) setSchema(s);
41+
})
42+
.catch((e: unknown) => {
43+
if (alive) setLoadError(e instanceof Error ? e.message : 'Could not load the add form.');
44+
});
45+
return () => {
46+
alive = false;
47+
};
48+
}, [client, appLabel, modelName]);
49+
50+
if (loadError) {
51+
return <EmptyState title="Couldn't open the add form" description={loadError} />;
52+
}
53+
if (!schema) return <Spinner label="Loading…" />;
54+
55+
return (
56+
<div className="space-y-4">
57+
<header>
58+
<Link to={`/${appLabel}/${modelName}`} className="text-sm text-blue-600 hover:underline">
59+
← Back to list
60+
</Link>
61+
<h1 className="mt-1 text-2xl font-semibold">Add {appLabel} · {modelName}</h1>
62+
</header>
63+
<CreateForm
64+
schema={schema}
65+
onCreate={async (payload) => {
66+
const created = await createObject({ client, appLabel, modelName, payload });
67+
navigate(`/${appLabel}/${modelName}/${created.pk}`);
68+
}}
69+
onCancel={() => navigate(`/${appLabel}/${modelName}`)}
70+
/>
71+
</div>
72+
);
73+
}
74+
75+
interface CreateFormProps {
76+
schema: AddFormResponse;
77+
onCreate: (payload: Record<string, WriteValue>) => Promise<void>;
78+
onCancel: () => void;
79+
}
80+
81+
function CreateForm({ schema, onCreate, onCancel }: CreateFormProps) {
82+
const [values, setValues] = useState<Record<string, WriteValue>>(() => {
83+
const init: Record<string, WriteValue> = {};
84+
for (const [name, field] of Object.entries(schema.fields)) {
85+
if (field.readonly) continue;
86+
const v = field.value;
87+
// Seed with the model default where the wire carries a scalar;
88+
// FK envelopes / arrays / html start empty for a new object.
89+
init[name] = v !== null && typeof v !== 'object' ? v : null;
90+
}
91+
return init;
92+
});
93+
const [errors, setErrors] = useState<Record<string, string[]>>({});
94+
const [nonFieldError, setNonFieldError] = useState<string | null>(null);
95+
const [saving, setSaving] = useState(false);
96+
97+
async function handleSubmit(e: React.FormEvent) {
98+
e.preventDefault();
99+
setSaving(true);
100+
setErrors({});
101+
setNonFieldError(null);
102+
try {
103+
await onCreate(values);
104+
} catch (err) {
105+
if (err instanceof ApiError && err.envelope?.error) {
106+
const fieldErrors = err.envelope.error.fields ?? {};
107+
setErrors(fieldErrors);
108+
if (Object.keys(fieldErrors).length === 0) {
109+
setNonFieldError(err.envelope.error.message || 'Create failed.');
110+
}
111+
} else {
112+
setNonFieldError(err instanceof Error ? err.message : 'Create failed.');
113+
}
114+
} finally {
115+
setSaving(false);
116+
}
117+
}
118+
119+
return (
120+
<form onSubmit={handleSubmit} className="space-y-4">
121+
{nonFieldError && (
122+
<div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
123+
{nonFieldError}
124+
</div>
125+
)}
126+
{schema.fieldsets.map((fieldset, idx) => (
127+
<Card
128+
key={`cfs-${idx}-${fieldset.title ?? 'default'}`}
129+
title={fieldset.title ?? undefined}
130+
>
131+
<div className="divide-y divide-gray-100">
132+
{fieldset.fields.map((name) => {
133+
const field = schema.fields[name];
134+
if (!field) return null;
135+
return (
136+
<FieldInput
137+
key={name}
138+
name={name}
139+
field={field}
140+
value={values[name] ?? null}
141+
error={errors[name]}
142+
onChange={(v) => setValues((prev) => ({ ...prev, [name]: v }))}
143+
/>
144+
);
145+
})}
146+
</div>
147+
</Card>
148+
))}
149+
<div className="flex gap-2">
150+
<Button type="submit" variant="primary" disabled={saving}>
151+
{saving ? 'Saving…' : 'Add'}
152+
</Button>
153+
<Button type="button" variant="secondary" onClick={onCancel} disabled={saving}>
154+
Cancel
155+
</Button>
156+
</div>
157+
</form>
158+
);
159+
}

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

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import { useEffect, useMemo, useState } from 'react';
99
import { ListFilter } from 'lucide-react';
10-
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
10+
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
1111

1212
import {
1313
useApiClient,
@@ -173,16 +173,27 @@ export function ListPage() {
173173

174174
return (
175175
<div className="space-y-4">
176-
<header>
177-
<h1 className="text-2xl font-semibold">
178-
<span className="capitalize">{appLabel}</span> ·{' '}
179-
{data.verbose_name_plural
180-
? capitalize(data.verbose_name_plural)
181-
: data.object_name || modelName}
182-
</h1>
183-
<p className="text-sm text-gray-500">
184-
{data.total.toLocaleString()} object{data.total === 1 ? '' : 's'}
185-
</p>
176+
<header className="flex items-start justify-between gap-4">
177+
<div>
178+
<h1 className="text-2xl font-semibold">
179+
<span className="capitalize">{appLabel}</span> ·{' '}
180+
{data.verbose_name_plural
181+
? capitalize(data.verbose_name_plural)
182+
: data.object_name || modelName}
183+
</h1>
184+
<p className="text-sm text-gray-500">
185+
{data.total.toLocaleString()} object{data.total === 1 ? '' : 's'}
186+
</p>
187+
</div>
188+
{data.permissions.add && (
189+
<Link
190+
to={`/${appLabel}/${modelName}/add`}
191+
className="shrink-0 rounded-md border border-blue-600 bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
192+
>
193+
+ Add{' '}
194+
{data.verbose_name ? capitalize(data.verbose_name) : modelName}
195+
</Link>
196+
)}
186197
</header>
187198

188199
{/* Toolbar row (#177 / #182): Actions dropdown (only when rows are

frontend/packages/api/src/client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import type {
88
ActionRunResponse,
9+
AddFormResponse,
910
CreatePayload,
1011
CreateResponse,
1112
DetailResponse,
@@ -173,6 +174,11 @@ export class ApiClient {
173174
return this.request<LoginResponse>('POST', 'login/', { username, password });
174175
}
175176

177+
/** The create-form schema for a NEW object (GET <app>/<model>/add/). */
178+
addForm(appLabel: string, modelName: string): Promise<AddFormResponse> {
179+
return this.request<AddFormResponse>('GET', `${appLabel}/${modelName}/add/`);
180+
}
181+
176182
create(appLabel: string, modelName: string, payload: CreatePayload): Promise<CreateResponse> {
177183
return this.request<CreateResponse>('POST', `${appLabel}/${modelName}/`, payload);
178184
}

frontend/packages/api/src/contract.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,20 @@ export interface DetailResponse {
246246
inlines: InlineDescriptor[];
247247
}
248248

249+
/**
250+
* Response of `GET /api/v1/<app>/<model>/add/` — the create-form schema
251+
* for a NEW object. Same field/fieldset shape as the detail response
252+
* (so one FieldInput renders both), minus the per-object bits (pk,
253+
* label, inlines). Field values carry the model defaults.
254+
*/
255+
export interface AddFormResponse {
256+
app_label: string;
257+
model_name: string;
258+
permissions: Permissions;
259+
fieldsets: FieldsetDescriptor[];
260+
fields: Record<string, FieldDescriptor>;
261+
}
262+
249263
export interface CreateResponse {
250264
pk: number | string;
251265
label: string;

frontend/packages/data/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type { ApiProviderProps } from './api-context';
1010
export { ApiClient, ApiError } from '@dar/api';
1111
export type {
1212
ActionDescriptor,
13+
AddFormResponse,
1314
ActionRunResponse,
1415
ApiClientConfig,
1516
ColumnDescriptor,

0 commit comments

Comments
 (0)