Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions frontend/packages/form/src/FieldInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<FieldInput
name="owner"
field={field({
type: 'foreignkey',
widget: 'raw_id',
to: { app_label: 'auth', model_name: 'user' },
})}
value={null}
error={undefined}
onChange={() => {}}
/>,
);
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(
<FieldInput
name="owner"
field={field({
type: 'foreignkey',
widget: 'raw_id',
to: { app_label: 'auth', model_name: 'user' },
})}
value={null}
error={undefined}
onChange={onChange}
/>,
);
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(
<FieldInput
name="owner"
field={field({
type: 'foreignkey',
widget: 'raw_id',
to: { app_label: 'auth', model_name: 'user' },
})}
value={'42'}
error={undefined}
onChange={onChange}
/>,
);
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(
<FieldInput
name="owner"
field={field({
type: 'foreignkey',
widget: 'raw_id',
to: { app_label: 'auth', model_name: 'user' },
})}
value={null}
error={undefined}
onChange={() => {}}
/>,
);
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(
<FieldInput
name="owner"
field={field({ type: 'foreignkey', widget: 'raw_id' })}
value={null}
error={undefined}
onChange={() => {}}
/>,
);
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(
<FieldInput
name="status"
field={field({ type: 'choice', widget: 'radio', choices })}
value={'published'}
error={undefined}
onChange={() => {}}
/>,
);
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(
<FieldInput
name="status"
field={field({ type: 'choice', widget: 'radio', choices })}
value={null}
error={undefined}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByLabelText('Archived'));
expect(onChange).toHaveBeenCalledWith('archived');
});
});
69 changes: 69 additions & 0 deletions frontend/packages/form/src/FieldInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<div className="flex items-center gap-2">
<input
id={id}
type="text"
inputMode="numeric"
value={value == null ? '' : String(value)}
onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
className={base}
/>
{target && (
<a
// Same-window? No — popup is the legacy admin's contract.
// Open the changelist in a new tab; the operator copies
// the pk back into the input.
href={`../../${target.app_label}/${target.model_name}/`}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 rounded border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50"
aria-label="Look up related object in a new tab"
>
Lookup ↗
</a>
)}
</div>
);
} else if (field.widget === 'radio' && field.type === 'choice' && field.choices) {
// radio_fields (#626 / #251). The consumer explicitly chose inline
// radio buttons over the default `<select>` — typically because the
// option set is small (2-5) and a radio bank is faster to scan than
// a click + dropdown. The wire ships the choices list verbatim;
// render one labeled radio per option. Falls through to the choice
// branch when `field.choices` is empty (defence — choice fields
// should always carry choices, but the type allows undefined).
const radioValue = value == null ? '' : String(value);
control = (
<div role="radiogroup" aria-labelledby={`${id}-label`} className="flex flex-wrap gap-x-4 gap-y-2">
{field.choices.map((c) => {
const key = String(c.value);
return (
<label key={key} className="flex items-center gap-1.5 text-sm">
<input
type="radio"
name={id}
value={key}
checked={radioValue === key}
onChange={() => onChange(c.value)}
className="h-4 w-4 text-gray-700 focus:ring-gray-500"
/>
{c.label}
</label>
);
})}
</div>
);
} else if (field.type === 'boolean') {
control = (
<Checkbox id={id} checked={value === true} onChange={(e) => onChange(e.target.checked)} />
Expand Down
Loading