Skip to content

Commit 532791d

Browse files
feat(spa): editable ArrayField via comma-delimited input (#242)
Follows the JSON/Duration editors: ArrayField was read-only. Render a comma-delimited text input mirroring Django's SimpleArrayField widget — the raw string is sent as-is and SimpleArrayField splits + coerces each element (a bad element surfaces as a normal field error). No backend change (rule 3). Values containing commas aren't supported, the same limitation as Django's default admin widget. - FieldType: add `array` (backend already emits it). - FieldInput: `array` → comma-delimited text input. - Seeding (DetailPage/CreatePage): seed the comma-joined value, checked before the M2M array branch so the scalar list isn't mapped to {id}. range remains the last read-only structured type (needs the multi-widget write path, #238) — #242 stays open for it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9ed3761 commit 532791d

5 files changed

Lines changed: 49 additions & 2 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ function CreateForm({ schema, onCreate, onCancel }: CreateFormProps) {
140140
init[name] = v === null || v === undefined ? null : JSON.stringify(v, null, 2);
141141
continue;
142142
}
143+
if (field.type === 'array') {
144+
// ArrayField editor (#242): seed the comma-joined default (string).
145+
init[name] = Array.isArray(v) ? v.join(',') : null;
146+
continue;
147+
}
143148
// Seed with the model default where the wire carries a scalar;
144149
// FK envelopes / html start empty for a new object.
145150
init[name] = v !== null && typeof v !== 'object' ? v : null;

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,12 @@ function initialValueFor(field: DetailResponse['fields'][string]): WriteValue {
352352
// a JSON array isn't mistaken for an M2M id list.
353353
return JSON.stringify(v, null, 2);
354354
}
355+
if (field.type === 'array') {
356+
// ArrayField editor (#242): seed the comma-joined value (string),
357+
// matching Django's SimpleArrayField widget. Checked before the M2M
358+
// array branch so the scalar list isn't mapped to {id} envelopes.
359+
return Array.isArray(v) ? v.join(',') : null;
360+
}
355361
if (Array.isArray(v)) {
356362
// M2M (#240): [{id,label}, ...] → [id, ...] (bare pks for the write).
357363
return v.map((item) =>

frontend/packages/api/src/contract.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ export type FieldType =
1818
| 'datetime'
1919
| 'time'
2020
| 'uuid'
21-
// Structured types the backend emits (#242). `json` and `duration`
22-
// have editable SPA widgets; others currently render read-only.
21+
// Structured types the backend emits (#242). `json` / `duration` /
22+
// `array` have editable SPA widgets; others currently render read-only.
2323
| 'json'
2424
| 'duration'
25+
| 'array'
2526
| 'choice'
2627
| 'foreignkey'
2728
| 'manytomany'

frontend/packages/form/src/FieldInput.test.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,24 @@ describe('FieldInput — structured editors (#242)', () => {
108108
expect(onChange).toHaveBeenCalledWith('1 00:00:00');
109109
});
110110

111+
it('renders a comma-delimited text input for an array field', () => {
112+
const onChange = vi.fn();
113+
render(
114+
<FieldInput
115+
name="tags"
116+
field={field({ type: 'array' })}
117+
value="a,b,c"
118+
error={undefined}
119+
onChange={onChange}
120+
/>,
121+
);
122+
const input = screen.getByRole('textbox');
123+
expect(input.tagName).toBe('INPUT');
124+
expect(input).toHaveValue('a,b,c');
125+
fireEvent.change(input, { target: { value: 'a,b' } });
126+
expect(onChange).toHaveBeenCalledWith('a,b');
127+
});
128+
111129
it('keeps a readonly json field read-only (no textbox)', () => {
112130
render(
113131
<FieldInput

frontend/packages/form/src/FieldInput.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,23 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr
252252
className={base}
253253
/>
254254
);
255+
} else if (field.type === 'array') {
256+
// ArrayField editor (#242): a comma-delimited text input, mirroring
257+
// Django's SimpleArrayField widget. The form seeds it as the
258+
// comma-joined value; the raw string is sent as-is and SimpleArrayField
259+
// splits + coerces each element (reporting a bad element as a normal
260+
// field error). Values containing commas aren't supported — the same
261+
// limitation as Django's default admin widget.
262+
control = (
263+
<input
264+
id={id}
265+
type="text"
266+
value={value == null ? '' : String(value)}
267+
placeholder="comma,separated,values"
268+
onChange={(e) => onChange(e.target.value)}
269+
className={base}
270+
/>
271+
);
255272
} else {
256273
// Fallback: render value read-only for any type without an editor.
257274
control = (

0 commit comments

Comments
 (0)