Skip to content

Commit f8f6fda

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
feat(spa): two-pane shuttle widget for filter_horizontal/vertical (#627) + 1.6.0 (#647)
Closes #627 — the cross-repo shuttle-widget chain is now complete. ## What When the API (1.2.0+) emits ``widget: "shuttle_h"`` or ``"shuttle_v"`` on an M2M field (from ``ModelAdmin.filter_horizontal`` / ``filter_vertical``), the SPA now renders Django's two-pane "available / chosen" shuttle widget instead of the single-list checkbox bank. Scales well past the ~50-option ceiling where the default falls over. ## Behaviour - **Two panes** — Available (unselected) + Chosen (selected). Side-by- side for ``shuttle_h``, stacked for ``shuttle_v``. Mobile collapses both orientations to a single column. - **Click to move** — clicking an item moves it to the other pane. Keyboard parity: Enter or Space on a focused option triggers the same move (the ``<li>`` carries ``role="option"`` + ``tabIndex=0``). - **Per-pane search** — each pane has its own filter input that narrows the visible items client-side (no server roundtrip — the choices are already inlined in the wire payload). - **"Choose all" / "Remove all"** — act on the FILTERED view, so typing a search term + clicking the link affects only those items. - **Order preservation** — moving items INTO Chosen appends; moving items OUT keeps the relative order of survivors. Matches Django. - **Numeric pk round-trip** — numeric choice values are emitted as numbers (not stringified) so the API receives the right type. ## Cross-repo chain | Repo | Released | |---|---| | ``django-admin-rest-api`` 1.2.0 | [PyPI](https://pypi.org/project/django-admin-rest-api/1.2.0/) — emits the new hints | | ``django-admin-react`` 1.6.0 | **this PR** — renders the widget | SPA's ``django-admin-rest-api`` constraint tightens ``^1.1.0`` → ``^1.2.0`` so the new hint is guaranteed available; consumers on 1.1.x with this SPA build would see the silent-noop pattern return (which is what landed #626 / #627 in the first place). ## Tests Eight new vitests in ``ShuttleSelect.test.tsx``: - Two-pane render (Available + Chosen). - Click-to-move both directions. - Per-pane search filter (case-insensitive). - Choose all / Remove all bulk actions over the filtered view. - Horizontal vs vertical orientation toggles the grid layout. - Numeric pk round-trip. ## Verification - ``poetry run pytest -q`` — **61 / 61 ✓** on Django 4.2.30 - ``pnpm test`` — **202 / 202 ✓** (up from 194; +8 new) - ``pnpm -r typecheck`` ✓ - ``pnpm lint`` ✓ - ``pnpm -w build`` ✓ ## Minor bump rationale ``1.5.1`` → ``1.6.0``. New user-visible widget per SemVer's "additive features" guideline. Matches the symmetric ``1.2.0`` minor bump on ``django-admin-rest-api``. Closes #627. Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b62ad28 commit f8f6fda

7 files changed

Lines changed: 483 additions & 8 deletions

File tree

frontend/packages/api/src/contract.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,12 @@ export type FieldType =
3838
* `raw_id_fields` (#251). `password` is a security boundary, not a layout
3939
* choice: it marks a field the admin routed through `PasswordInput`, whose
4040
* stored value the backend redacts from the payload (matching Django's
41-
* `render_value=False`) — the SPA masks the input (#504).
41+
* `render_value=False`) — the SPA masks the input (#504). `shuttle_h` /
42+
* `shuttle_v` come from `filter_horizontal` / `filter_vertical` (#627) —
43+
* the SPA renders Django's two-pane "available / chosen" widget for the
44+
* M2M field, with horizontal or vertical orientation respectively.
4245
*/
43-
export type WidgetHint = 'radio' | 'raw_id' | 'password';
46+
export type WidgetHint = 'radio' | 'raw_id' | 'password' | 'shuttle_h' | 'shuttle_v';
4447

4548
export interface Permissions {
4649
view: boolean;

frontend/packages/form/src/FieldInput.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { Checkbox } from '@dar/ui';
1717

1818
import { AutocompleteInput } from './AutocompleteInput';
1919
import { RelatedAddModal } from './RelatedAddModal';
20+
import { ShuttleSelect } from './ShuttleSelect';
2021

2122
interface FieldInputProps {
2223
name: string;
@@ -163,6 +164,29 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr
163164
onChange={onChange}
164165
/>
165166
);
167+
} else if (
168+
field.type === 'manytomany' &&
169+
(field.widget === 'shuttle_h' || field.widget === 'shuttle_v') &&
170+
field.choices &&
171+
field.choices.length > 0
172+
) {
173+
// filter_horizontal / filter_vertical (#627). The admin opted into
174+
// Django's two-pane "available / chosen" shuttle widget — the API
175+
// emits `widget: "shuttle_h"` or `"shuttle_v"` (api 1.2.0+) and
176+
// we render a real shuttle with per-pane search, selection-order
177+
// preservation, and "Choose all / Remove all" bulk actions.
178+
// Scales well past the ~50-option ceiling where the default
179+
// checkbox list breaks down.
180+
control = (
181+
<ShuttleSelect
182+
id={id}
183+
choices={field.choices}
184+
value={value}
185+
orientation={field.widget === 'shuttle_v' ? 'v' : 'h'}
186+
label={field.label}
187+
onChange={(next) => onChange(next as unknown as WriteValue)}
188+
/>
189+
);
166190
} else if (field.type === 'manytomany') {
167191
// ManyToMany write (#240). The backend accepts a list of pks
168192
// (form.save_m2m). When the target set is small the descriptor
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// ShuttleSelect — locks the two-pane render + selection behaviour
2+
// (#627): which items live in which pane, click-to-move, search
3+
// filter per pane, "Choose all" / "Remove all" buttons, order
4+
// preservation when moving items in and out of Chosen.
5+
import '@testing-library/jest-dom/vitest';
6+
7+
import { fireEvent, render, screen, within } from '@testing-library/react';
8+
import { describe, expect, it, vi } from 'vitest';
9+
10+
import type { FieldChoice } from '@dar/data';
11+
12+
import { ShuttleSelect } from './ShuttleSelect';
13+
14+
const CHOICES: FieldChoice[] = [
15+
{ value: 1, label: 'Engineering' },
16+
{ value: 2, label: 'Marketing' },
17+
{ value: 3, label: 'Finance' },
18+
{ value: 4, label: 'Operations' },
19+
];
20+
21+
function paneByTitle(title: string): HTMLElement {
22+
// Each Pane renders an `<ul role="listbox" aria-labelledby="...">`
23+
// pointing to a `<div>` carrying the title text.
24+
return screen.getByRole('listbox', { name: title });
25+
}
26+
27+
describe('ShuttleSelect (#627)', () => {
28+
it('renders two panes: Available (unselected) + Chosen (selected)', () => {
29+
render(
30+
<ShuttleSelect
31+
id="t"
32+
choices={CHOICES}
33+
value={[2]}
34+
orientation="h"
35+
label="departments"
36+
onChange={() => {}}
37+
/>,
38+
);
39+
const avail = paneByTitle('Available departments');
40+
const chosen = paneByTitle('Chosen departments');
41+
expect(within(avail).getByText('Engineering')).toBeInTheDocument();
42+
expect(within(avail).getByText('Finance')).toBeInTheDocument();
43+
expect(within(avail).getByText('Operations')).toBeInTheDocument();
44+
expect(within(avail).queryByText('Marketing')).not.toBeInTheDocument();
45+
expect(within(chosen).getByText('Marketing')).toBeInTheDocument();
46+
});
47+
48+
it('clicking an Available item appends it to Chosen (preserves order)', () => {
49+
const onChange = vi.fn();
50+
render(
51+
<ShuttleSelect
52+
id="t"
53+
choices={CHOICES}
54+
value={[2]}
55+
orientation="h"
56+
label="departments"
57+
onChange={onChange}
58+
/>,
59+
);
60+
fireEvent.click(within(paneByTitle('Available departments')).getByText('Finance'));
61+
expect(onChange).toHaveBeenCalledWith([2, 3]);
62+
});
63+
64+
it('clicking a Chosen item removes it', () => {
65+
const onChange = vi.fn();
66+
render(
67+
<ShuttleSelect
68+
id="t"
69+
choices={CHOICES}
70+
value={[1, 2, 3]}
71+
orientation="h"
72+
label="departments"
73+
onChange={onChange}
74+
/>,
75+
);
76+
fireEvent.click(within(paneByTitle('Chosen departments')).getByText('Marketing'));
77+
expect(onChange).toHaveBeenCalledWith([1, 3]);
78+
});
79+
80+
it('search filters items within a pane (case-insensitive)', () => {
81+
render(
82+
<ShuttleSelect
83+
id="t"
84+
choices={CHOICES}
85+
value={[]}
86+
orientation="h"
87+
label="departments"
88+
onChange={() => {}}
89+
/>,
90+
);
91+
const availSearch = screen.getByLabelText('Filter Available departments');
92+
fireEvent.change(availSearch, { target: { value: 'fin' } });
93+
const avail = paneByTitle('Available departments');
94+
expect(within(avail).getByText('Finance')).toBeInTheDocument();
95+
expect(within(avail).queryByText('Engineering')).not.toBeInTheDocument();
96+
});
97+
98+
it('Choose all moves every FILTERED Available item to Chosen', () => {
99+
const onChange = vi.fn();
100+
render(
101+
<ShuttleSelect
102+
id="t"
103+
choices={CHOICES}
104+
value={[]}
105+
orientation="h"
106+
label="departments"
107+
onChange={onChange}
108+
/>,
109+
);
110+
// Narrow the visible set to Engineering + Operations (anything with `o`).
111+
const availSearch = screen.getByLabelText('Filter Available departments');
112+
fireEvent.change(availSearch, { target: { value: 'O' } });
113+
fireEvent.click(screen.getByRole('button', { name: 'Choose all' }));
114+
// Marketing matches "O" too (mArketing has no O — but "ratiOns" /
115+
// "engineering" / "operatiOns" do). Be explicit.
116+
const want = CHOICES.filter((c) => c.label.toLowerCase().includes('o')).map((c) => c.value);
117+
expect(onChange).toHaveBeenCalledWith(want);
118+
});
119+
120+
it('Remove all clears every FILTERED Chosen item', () => {
121+
const onChange = vi.fn();
122+
render(
123+
<ShuttleSelect
124+
id="t"
125+
choices={CHOICES}
126+
value={[1, 2, 3, 4]}
127+
orientation="h"
128+
label="departments"
129+
onChange={onChange}
130+
/>,
131+
);
132+
// Filter Chosen by "ing" — matches Engineering + Marketing only
133+
// (Finance + Operations have no "ing"). Removes those two; the
134+
// surviving Chosen set is [3, 4] in order.
135+
const chosenSearch = screen.getByLabelText('Filter Chosen departments');
136+
fireEvent.change(chosenSearch, { target: { value: 'ing' } });
137+
fireEvent.click(screen.getByRole('button', { name: 'Remove all' }));
138+
expect(onChange).toHaveBeenCalledWith([3, 4]);
139+
});
140+
141+
it('horizontal vs vertical orientation toggles the grid layout', () => {
142+
const { container, rerender } = render(
143+
<ShuttleSelect
144+
id="t"
145+
choices={CHOICES}
146+
value={[]}
147+
orientation="h"
148+
label="departments"
149+
onChange={() => {}}
150+
/>,
151+
);
152+
const hRoot = container.firstChild as HTMLElement;
153+
expect(hRoot.className).toContain('sm:grid-cols-[1fr_1fr]');
154+
rerender(
155+
<ShuttleSelect
156+
id="t"
157+
choices={CHOICES}
158+
value={[]}
159+
orientation="v"
160+
label="departments"
161+
onChange={() => {}}
162+
/>,
163+
);
164+
const vRoot = container.firstChild as HTMLElement;
165+
expect(vRoot.className).not.toContain('sm:grid-cols-[1fr_1fr]');
166+
});
167+
168+
it('round-trips numeric pks as numbers (not strings) on emit', () => {
169+
const onChange = vi.fn();
170+
render(
171+
<ShuttleSelect
172+
id="t"
173+
choices={CHOICES}
174+
value={[1]}
175+
orientation="h"
176+
label="departments"
177+
onChange={onChange}
178+
/>,
179+
);
180+
fireEvent.click(within(paneByTitle('Available departments')).getByText('Finance'));
181+
const [emitted] = onChange.mock.calls[0] as [Array<string | number>];
182+
expect(emitted).toEqual([1, 3]);
183+
for (const v of emitted) expect(typeof v).toBe('number');
184+
});
185+
});

0 commit comments

Comments
 (0)