diff --git a/frontend/packages/form/src/FieldInput.test.tsx b/frontend/packages/form/src/FieldInput.test.tsx index 1d2778a..a3e499c 100644 --- a/frontend/packages/form/src/FieldInput.test.tsx +++ b/frontend/packages/form/src/FieldInput.test.tsx @@ -225,3 +225,137 @@ describe('FieldInput — related "+add" affordance (#383)', () => { expect(screen.queryByRole('button', { name: /Add/ })).not.toBeInTheDocument(); }); }); + +describe('FieldInput raw_id widget (#626)', () => { + it('renders a plain text input — NOT the autocomplete picker', () => { + render( + {}} + />, + ); + const input = screen.getByLabelText('F') as HTMLInputElement; + expect(input).toHaveAttribute('type', 'text'); + // Defining feature: no autocomplete + numeric inputMode for the pk. + expect(input).toHaveAttribute('inputmode', 'numeric'); + }); + + it('reports the typed pk via onChange', () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.change(screen.getByLabelText('F'), { target: { value: '42' } }); + expect(onChange).toHaveBeenCalledWith('42'); + }); + + it('emits null when the operator clears a previously-filled pk', () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.change(screen.getByLabelText('F'), { target: { value: '' } }); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('shows a Lookup link to the FK target changelist (opens in a new tab)', () => { + render( + {}} + />, + ); + const lookup = screen.getByRole('link', { name: /Look up related object/ }); + expect(lookup).toHaveAttribute('href', '../../auth/user/'); + expect(lookup).toHaveAttribute('target', '_blank'); + expect(lookup).toHaveAttribute('rel', expect.stringContaining('noopener') as unknown as string); + }); + + it('omits the Lookup link when the FK target is not admin-registered (no `to`)', () => { + render( + {}} + />, + ); + expect(screen.queryByRole('link', { name: /Look up/ })).not.toBeInTheDocument(); + }); +}); + +describe('FieldInput radio widget (#626)', () => { + const choices = [ + { value: 'draft', label: 'Draft' }, + { value: 'published', label: 'Published' }, + { value: 'archived', label: 'Archived' }, + ]; + + it('renders one radio input per choice, grouped under a radiogroup', () => { + render( + {}} + />, + ); + expect(screen.getByRole('radiogroup')).toBeInTheDocument(); + expect(screen.getAllByRole('radio')).toHaveLength(3); + // The currently-selected option is checked. + const checked = screen.getAllByRole('radio').find((r) => (r as HTMLInputElement).checked); + expect(checked).toBeDefined(); + expect((checked as HTMLInputElement).value).toBe('published'); + }); + + it('emits the original (untyped) choice value via onChange', () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByLabelText('Archived')); + expect(onChange).toHaveBeenCalledWith('archived'); + }); +}); diff --git a/frontend/packages/form/src/FieldInput.tsx b/frontend/packages/form/src/FieldInput.tsx index f533d39..289d6eb 100644 --- a/frontend/packages/form/src/FieldInput.tsx +++ b/frontend/packages/form/src/FieldInput.tsx @@ -69,6 +69,75 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr className={base} /> ); + } else if (field.widget === 'raw_id' && field.type === 'foreignkey') { + // raw_id_fields (#626 / #251). The consumer explicitly OPTED OUT + // of an autocomplete picker — typically because the FK target has + // millions of rows and `get_search_results` is too expensive. + // Match Django's HTML admin: a bare pk text input, plus a small + // "lookup" link that opens the FK target's changelist in a new + // tab so the operator can find the pk by sight. No autocomplete + // request fires. The wire ships `field.to = {app_label, model_name}` + // when the FK target is admin-registered (`#184` parity); when it + // isn't, we render the input only. + // + // M2M + `raw_id` is intentionally NOT handled here; falls through + // to the existing M2M branch (CSV pk list is a separate follow-up). + const target = field.to; + control = ( +
+ onChange(e.target.value === '' ? null : e.target.value)} + className={base} + /> + {target && ( + + Lookup ↗ + + )} +
+ ); + } else if (field.widget === 'radio' && field.type === 'choice' && field.choices) { + // radio_fields (#626 / #251). The consumer explicitly chose inline + // radio buttons over the default ` onChange(c.value)} + className="h-4 w-4 text-gray-700 focus:ring-gray-500" + /> + {c.label} + + ); + })} + + ); } else if (field.type === 'boolean') { control = ( onChange(e.target.checked)} />