Skip to content

Commit 5a0c23a

Browse files
feat(spa): render raw_id_fields and radio_fields hints (#626)
The wire contract already declared `WidgetHint = 'radio' | 'raw_id' | 'password'`, but only `'password'` had a render path; the other two were silently no-ops — a ModelAdmin declaring `raw_id_fields = ('owner',)` got the autocomplete picker anyway (the very thing the consumer was OPTING OUT of), and `radio_fields = {"status": admin.HORIZONTAL}` rendered as a `<select>`. This adds the two render branches in `FieldInput.tsx`: - **`widget === 'raw_id'` on a `foreignkey`** → plain pk text input (no autocomplete request, no popup picker) + a small "Lookup ↗" link that opens the FK target's changelist in a new tab so the operator can find the pk by sight. Matches Django HTML admin's approach for `raw_id_fields` on FKs with too many rows to autocomplete. The link is omitted when `field.to` is absent (FK target not admin-registered). - **`widget === 'radio'` on a `choice` with `choices`** → inline radio bank under a `<div role="radiogroup">`. Emits the original choice value (not the stringified key) so number/bool choices round-trip. M2M + `raw_id` is intentionally not handled here (legacy admin renders a CSV pk list textarea — separate follow-up); the guard `field.type === 'foreignkey'` lets M2M fall through to the existing M2M branch. InlineEditor needs no change: its `InlineCellInput` already text-inputs anything non-password/boolean/numeric, which IS the `raw_id` rendering for a table-shaped inline cell. Locks: 6 new vitests in `FieldInput.test.tsx` cover the rendering, the lookup-link presence/absence, the onChange pk emission, the clear-to-null path, and the radio group's checked + onChange behaviour. Closes #626. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 00ffa77 commit 5a0c23a

2 files changed

Lines changed: 203 additions & 0 deletions

File tree

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

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,137 @@ describe('FieldInput — related "+add" affordance (#383)', () => {
225225
expect(screen.queryByRole('button', { name: /Add/ })).not.toBeInTheDocument();
226226
});
227227
});
228+
229+
describe('FieldInput raw_id widget (#626)', () => {
230+
it('renders a plain text input — NOT the autocomplete picker', () => {
231+
render(
232+
<FieldInput
233+
name="owner"
234+
field={field({
235+
type: 'foreignkey',
236+
widget: 'raw_id',
237+
to: { app_label: 'auth', model_name: 'user' },
238+
})}
239+
value={null}
240+
error={undefined}
241+
onChange={() => {}}
242+
/>,
243+
);
244+
const input = screen.getByLabelText('F') as HTMLInputElement;
245+
expect(input).toHaveAttribute('type', 'text');
246+
// Defining feature: no autocomplete + numeric inputMode for the pk.
247+
expect(input).toHaveAttribute('inputmode', 'numeric');
248+
});
249+
250+
it('reports the typed pk via onChange', () => {
251+
const onChange = vi.fn();
252+
render(
253+
<FieldInput
254+
name="owner"
255+
field={field({
256+
type: 'foreignkey',
257+
widget: 'raw_id',
258+
to: { app_label: 'auth', model_name: 'user' },
259+
})}
260+
value={null}
261+
error={undefined}
262+
onChange={onChange}
263+
/>,
264+
);
265+
fireEvent.change(screen.getByLabelText('F'), { target: { value: '42' } });
266+
expect(onChange).toHaveBeenCalledWith('42');
267+
});
268+
269+
it('emits null when the operator clears a previously-filled pk', () => {
270+
const onChange = vi.fn();
271+
render(
272+
<FieldInput
273+
name="owner"
274+
field={field({
275+
type: 'foreignkey',
276+
widget: 'raw_id',
277+
to: { app_label: 'auth', model_name: 'user' },
278+
})}
279+
value={'42'}
280+
error={undefined}
281+
onChange={onChange}
282+
/>,
283+
);
284+
fireEvent.change(screen.getByLabelText('F'), { target: { value: '' } });
285+
expect(onChange).toHaveBeenCalledWith(null);
286+
});
287+
288+
it('shows a Lookup link to the FK target changelist (opens in a new tab)', () => {
289+
render(
290+
<FieldInput
291+
name="owner"
292+
field={field({
293+
type: 'foreignkey',
294+
widget: 'raw_id',
295+
to: { app_label: 'auth', model_name: 'user' },
296+
})}
297+
value={null}
298+
error={undefined}
299+
onChange={() => {}}
300+
/>,
301+
);
302+
const lookup = screen.getByRole('link', { name: /Look up related object/ });
303+
expect(lookup).toHaveAttribute('href', '../../auth/user/');
304+
expect(lookup).toHaveAttribute('target', '_blank');
305+
expect(lookup).toHaveAttribute('rel', expect.stringContaining('noopener') as unknown as string);
306+
});
307+
308+
it('omits the Lookup link when the FK target is not admin-registered (no `to`)', () => {
309+
render(
310+
<FieldInput
311+
name="owner"
312+
field={field({ type: 'foreignkey', widget: 'raw_id' })}
313+
value={null}
314+
error={undefined}
315+
onChange={() => {}}
316+
/>,
317+
);
318+
expect(screen.queryByRole('link', { name: /Look up/ })).not.toBeInTheDocument();
319+
});
320+
});
321+
322+
describe('FieldInput radio widget (#626)', () => {
323+
const choices = [
324+
{ value: 'draft', label: 'Draft' },
325+
{ value: 'published', label: 'Published' },
326+
{ value: 'archived', label: 'Archived' },
327+
];
328+
329+
it('renders one radio input per choice, grouped under a radiogroup', () => {
330+
render(
331+
<FieldInput
332+
name="status"
333+
field={field({ type: 'choice', widget: 'radio', choices })}
334+
value={'published'}
335+
error={undefined}
336+
onChange={() => {}}
337+
/>,
338+
);
339+
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
340+
expect(screen.getAllByRole('radio')).toHaveLength(3);
341+
// The currently-selected option is checked.
342+
const checked = screen.getAllByRole('radio').find((r) => (r as HTMLInputElement).checked);
343+
expect(checked).toBeDefined();
344+
expect((checked as HTMLInputElement).value).toBe('published');
345+
});
346+
347+
it('emits the original (untyped) choice value via onChange', () => {
348+
const onChange = vi.fn();
349+
render(
350+
<FieldInput
351+
name="status"
352+
field={field({ type: 'choice', widget: 'radio', choices })}
353+
value={null}
354+
error={undefined}
355+
onChange={onChange}
356+
/>,
357+
);
358+
fireEvent.click(screen.getByLabelText('Archived'));
359+
expect(onChange).toHaveBeenCalledWith('archived');
360+
});
361+
});

frontend/packages/form/src/FieldInput.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,75 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr
6969
className={base}
7070
/>
7171
);
72+
} else if (field.widget === 'raw_id' && field.type === 'foreignkey') {
73+
// raw_id_fields (#626 / #251). The consumer explicitly OPTED OUT
74+
// of an autocomplete picker — typically because the FK target has
75+
// millions of rows and `get_search_results` is too expensive.
76+
// Match Django's HTML admin: a bare pk text input, plus a small
77+
// "lookup" link that opens the FK target's changelist in a new
78+
// tab so the operator can find the pk by sight. No autocomplete
79+
// request fires. The wire ships `field.to = {app_label, model_name}`
80+
// when the FK target is admin-registered (`#184` parity); when it
81+
// isn't, we render the input only.
82+
//
83+
// M2M + `raw_id` is intentionally NOT handled here; falls through
84+
// to the existing M2M branch (CSV pk list is a separate follow-up).
85+
const target = field.to;
86+
control = (
87+
<div className="flex items-center gap-2">
88+
<input
89+
id={id}
90+
type="text"
91+
inputMode="numeric"
92+
value={value == null ? '' : String(value)}
93+
onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
94+
className={base}
95+
/>
96+
{target && (
97+
<a
98+
// Same-window? No — popup is the legacy admin's contract.
99+
// Open the changelist in a new tab; the operator copies
100+
// the pk back into the input.
101+
href={`../../${target.app_label}/${target.model_name}/`}
102+
target="_blank"
103+
rel="noopener noreferrer"
104+
className="shrink-0 rounded border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50"
105+
aria-label="Look up related object in a new tab"
106+
>
107+
Lookup ↗
108+
</a>
109+
)}
110+
</div>
111+
);
112+
} else if (field.widget === 'radio' && field.type === 'choice' && field.choices) {
113+
// radio_fields (#626 / #251). The consumer explicitly chose inline
114+
// radio buttons over the default `<select>` — typically because the
115+
// option set is small (2-5) and a radio bank is faster to scan than
116+
// a click + dropdown. The wire ships the choices list verbatim;
117+
// render one labeled radio per option. Falls through to the choice
118+
// branch when `field.choices` is empty (defence — choice fields
119+
// should always carry choices, but the type allows undefined).
120+
const radioValue = value == null ? '' : String(value);
121+
control = (
122+
<div role="radiogroup" aria-labelledby={`${id}-label`} className="flex flex-wrap gap-x-4 gap-y-2">
123+
{field.choices.map((c) => {
124+
const key = String(c.value);
125+
return (
126+
<label key={key} className="flex items-center gap-1.5 text-sm">
127+
<input
128+
type="radio"
129+
name={id}
130+
value={key}
131+
checked={radioValue === key}
132+
onChange={() => onChange(c.value)}
133+
className="h-4 w-4 text-gray-700 focus:ring-gray-500"
134+
/>
135+
{c.label}
136+
</label>
137+
);
138+
})}
139+
</div>
140+
);
72141
} else if (field.type === 'boolean') {
73142
control = (
74143
<Checkbox id={id} checked={value === true} onChange={(e) => onChange(e.target.checked)} />

0 commit comments

Comments
 (0)