Skip to content

Commit f1e3fbd

Browse files
feat(spa): write surface — edit + delete on the detail view
The SPA could read everything but write nothing. This adds the core write capability: an existing object can now be edited and deleted from /admin2/, gated by the `permissions` block the API returns. Integrates with the inlines read-rendering already on the detail view (#54). - FieldInput.tsx (new) — one editable control per FieldDescriptor, mapping the wire `type` vocabulary to an HTML input (text / textarea / number / checkbox / date / datetime-local / time / select). `choice` + `foreignkey` with inlined `choices` render a <select>; FK without choices (large target table) falls back to a bare-pk input showing the current label (autocomplete widget is a follow-up). readonly + `unsupported` render the value, not an input. - DetailPage.tsx — Edit button (shown when `permissions.change`) toggles an inline form from the same fieldsets; Save PATCHes via `updateObject` and surfaces field-level errors from the validation envelope (`ApiError.envelope.error.fields`) with a non-field-error banner fallback; Cancel reverts. Delete button (when `permissions.delete`) confirms inline, DELETEs, returns to the list. The read view (fieldsets + inline tables/cards from #54) is unchanged. - @dar/data — re-export `WriteValue`. Writes still go through `ModelAdmin.get_form()` → `is_valid()` → `save_model()` / `delete_model()` on the backend; the SPA only builds the payload and renders errors. FK sends the bare pk (wire §5.1). Create (needs an add-form schema endpoint) + inline/file write are tracked in #160. Verified live against the laminr pilot: PATCH of a bank `name` via the real CSRF-protected endpoint returns 200 and persists; Edit/Delete are hidden when the admin denies change/delete. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d009a5a commit f1e3fbd

3 files changed

Lines changed: 435 additions & 44 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// FieldInput — one editable control for a FieldDescriptor.
2+
//
3+
// Maps the wire `type` vocabulary to an HTML input. Read-only fields
4+
// and `unsupported` types render their value (not editable). FK fields
5+
// render a <select> when the descriptor inlines `choices` (≤25 target
6+
// rows), else a bare-pk text input with the current label shown (the
7+
// autocomplete widget is a follow-up). The form value produced is a
8+
// `WriteValue` (string | number | boolean | null) — FK sends the bare
9+
// pk, per the wire contract §5.1.
10+
11+
import type { FieldDescriptor, FieldValue, WriteValue } from '@dar/data';
12+
13+
import { FieldValueView } from './FieldValueView';
14+
15+
interface FieldInputProps {
16+
name: string;
17+
field: FieldDescriptor;
18+
value: WriteValue;
19+
error: string[] | undefined;
20+
onChange: (value: WriteValue) => void;
21+
}
22+
23+
const TEXTLIKE = new Set(['string', 'email', 'url', 'slug', 'uuid']);
24+
25+
function fkId(value: FieldValue): WriteValue {
26+
if (value && typeof value === 'object' && !Array.isArray(value) && 'id' in value) {
27+
return value.id;
28+
}
29+
return null;
30+
}
31+
32+
export function FieldInput({ name, field, value, error, onChange }: FieldInputProps) {
33+
const id = `dar-input-${name}`;
34+
const base =
35+
'w-full rounded border px-2 py-1 text-sm ' +
36+
(error?.length ? 'border-red-400 focus:ring-red-500' : 'border-gray-300 focus:ring-gray-500');
37+
38+
// Read-only / unsupported → show the value, no input.
39+
if (field.readonly || field.type === 'unsupported') {
40+
return (
41+
<Row id={id} field={field} error={error}>
42+
<div className="text-sm text-gray-700">
43+
<FieldValueView value={field.value} />
44+
</div>
45+
</Row>
46+
);
47+
}
48+
49+
let control: React.ReactNode;
50+
51+
if (field.type === 'boolean') {
52+
control = (
53+
<input
54+
id={id}
55+
type="checkbox"
56+
checked={value === true}
57+
onChange={(e) => onChange(e.target.checked)}
58+
className="h-4 w-4 rounded border-gray-300"
59+
/>
60+
);
61+
} else if (field.type === 'text') {
62+
control = (
63+
<textarea
64+
id={id}
65+
value={value == null ? '' : String(value)}
66+
onChange={(e) => onChange(e.target.value)}
67+
rows={4}
68+
className={base}
69+
/>
70+
);
71+
} else if (field.type === 'choice' || field.type === 'foreignkey') {
72+
const choices = field.choices ?? [];
73+
if (choices.length > 0) {
74+
const current = field.type === 'foreignkey' ? fkId(field.value) : value;
75+
control = (
76+
<select
77+
id={id}
78+
value={current == null ? '' : String(current)}
79+
onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
80+
className={base}
81+
>
82+
<option value="">{field.required ? '— select —' : '(none)'}</option>
83+
{choices.map((c) => (
84+
<option key={String(c.value)} value={String(c.value)}>
85+
{c.label}
86+
</option>
87+
))}
88+
</select>
89+
);
90+
} else {
91+
// FK with no inlined choices (large target table). Bare-pk input
92+
// + current label hint until the autocomplete widget lands.
93+
const current = fkId(field.value);
94+
control = (
95+
<div>
96+
<input
97+
id={id}
98+
type="text"
99+
defaultValue={current == null ? '' : String(current)}
100+
onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
101+
placeholder="related object id"
102+
className={base}
103+
/>
104+
{field.value && typeof field.value === 'object' && 'label' in field.value && (
105+
<p className="mt-1 text-xs text-gray-400">
106+
current: {(field.value as { label: string }).label}
107+
</p>
108+
)}
109+
</div>
110+
);
111+
}
112+
} else if (field.type === 'integer' || field.type === 'float' || field.type === 'decimal') {
113+
control = (
114+
<input
115+
id={id}
116+
type="number"
117+
step={field.type === 'integer' ? '1' : 'any'}
118+
value={value == null ? '' : String(value)}
119+
onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
120+
className={base}
121+
/>
122+
);
123+
} else if (field.type === 'date') {
124+
control = (
125+
<input
126+
id={id}
127+
type="date"
128+
value={value == null ? '' : String(value)}
129+
onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
130+
className={base}
131+
/>
132+
);
133+
} else if (field.type === 'datetime') {
134+
// The wire form is ISO 8601; <input type=datetime-local> wants
135+
// "YYYY-MM-DDTHH:MM". Trim the seconds/zone for the control, send
136+
// back what the user picked (Django parses it).
137+
const v = value == null ? '' : String(value).slice(0, 16);
138+
control = (
139+
<input
140+
id={id}
141+
type="datetime-local"
142+
value={v}
143+
onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
144+
className={base}
145+
/>
146+
);
147+
} else if (field.type === 'time') {
148+
control = (
149+
<input
150+
id={id}
151+
type="time"
152+
value={value == null ? '' : String(value).slice(0, 8)}
153+
onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
154+
className={base}
155+
/>
156+
);
157+
} else if (TEXTLIKE.has(field.type)) {
158+
control = (
159+
<input
160+
id={id}
161+
type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'}
162+
value={value == null ? '' : String(value)}
163+
maxLength={field.max_length}
164+
onChange={(e) => onChange(e.target.value)}
165+
className={base}
166+
/>
167+
);
168+
} else {
169+
// Fallback: render value read-only for any type without an editor.
170+
control = (
171+
<div className="text-sm text-gray-700">
172+
<FieldValueView value={field.value} />
173+
</div>
174+
);
175+
}
176+
177+
return (
178+
<Row id={id} field={field} error={error}>
179+
{control}
180+
</Row>
181+
);
182+
}
183+
184+
interface RowProps {
185+
id: string;
186+
field: FieldDescriptor;
187+
error: string[] | undefined;
188+
children: React.ReactNode;
189+
}
190+
191+
function Row({ id, field, error, children }: RowProps) {
192+
return (
193+
<div className="py-2 grid grid-cols-3 gap-4 text-sm items-start">
194+
<label htmlFor={id} className="text-gray-500 pt-1">
195+
{field.label}
196+
{field.required && !field.readonly ? <span className="text-red-500"> *</span> : null}
197+
</label>
198+
<div className="col-span-2">
199+
{children}
200+
{field.help_text ? <p className="mt-1 text-xs text-gray-400">{field.help_text}</p> : null}
201+
{error?.length ? <p className="mt-1 text-xs text-red-600">{error.join(' ')}</p> : null}
202+
</div>
203+
</div>
204+
);
205+
}

0 commit comments

Comments
 (0)