From cc43b7b21da3ba840e5a66e3f129831b0f2b0e48 Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs Date: Sun, 31 May 2026 14:25:15 +0200 Subject: [PATCH] feat(spa): two-pane shuttle widget for filter_horizontal/vertical (#627) + 1.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``
  • `` 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: Claude Opus 4.7 (1M context) --- frontend/packages/api/src/contract.ts | 7 +- frontend/packages/form/src/FieldInput.tsx | 24 ++ .../packages/form/src/ShuttleSelect.test.tsx | 185 +++++++++++++ frontend/packages/form/src/ShuttleSelect.tsx | 262 ++++++++++++++++++ frontend/packages/form/src/index.ts | 1 + poetry.lock | 8 +- pyproject.toml | 4 +- 7 files changed, 483 insertions(+), 8 deletions(-) create mode 100644 frontend/packages/form/src/ShuttleSelect.test.tsx create mode 100644 frontend/packages/form/src/ShuttleSelect.tsx diff --git a/frontend/packages/api/src/contract.ts b/frontend/packages/api/src/contract.ts index 0bba0a91..8618e1da 100644 --- a/frontend/packages/api/src/contract.ts +++ b/frontend/packages/api/src/contract.ts @@ -38,9 +38,12 @@ export type FieldType = * `raw_id_fields` (#251). `password` is a security boundary, not a layout * choice: it marks a field the admin routed through `PasswordInput`, whose * stored value the backend redacts from the payload (matching Django's - * `render_value=False`) — the SPA masks the input (#504). + * `render_value=False`) — the SPA masks the input (#504). `shuttle_h` / + * `shuttle_v` come from `filter_horizontal` / `filter_vertical` (#627) — + * the SPA renders Django's two-pane "available / chosen" widget for the + * M2M field, with horizontal or vertical orientation respectively. */ -export type WidgetHint = 'radio' | 'raw_id' | 'password'; +export type WidgetHint = 'radio' | 'raw_id' | 'password' | 'shuttle_h' | 'shuttle_v'; export interface Permissions { view: boolean; diff --git a/frontend/packages/form/src/FieldInput.tsx b/frontend/packages/form/src/FieldInput.tsx index 289d6eb3..fff1ecbb 100644 --- a/frontend/packages/form/src/FieldInput.tsx +++ b/frontend/packages/form/src/FieldInput.tsx @@ -17,6 +17,7 @@ import { Checkbox } from '@dar/ui'; import { AutocompleteInput } from './AutocompleteInput'; import { RelatedAddModal } from './RelatedAddModal'; +import { ShuttleSelect } from './ShuttleSelect'; interface FieldInputProps { name: string; @@ -163,6 +164,29 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr onChange={onChange} /> ); + } else if ( + field.type === 'manytomany' && + (field.widget === 'shuttle_h' || field.widget === 'shuttle_v') && + field.choices && + field.choices.length > 0 + ) { + // filter_horizontal / filter_vertical (#627). The admin opted into + // Django's two-pane "available / chosen" shuttle widget — the API + // emits `widget: "shuttle_h"` or `"shuttle_v"` (api 1.2.0+) and + // we render a real shuttle with per-pane search, selection-order + // preservation, and "Choose all / Remove all" bulk actions. + // Scales well past the ~50-option ceiling where the default + // checkbox list breaks down. + control = ( + onChange(next as unknown as WriteValue)} + /> + ); } else if (field.type === 'manytomany') { // ManyToMany write (#240). The backend accepts a list of pks // (form.save_m2m). When the target set is small the descriptor diff --git a/frontend/packages/form/src/ShuttleSelect.test.tsx b/frontend/packages/form/src/ShuttleSelect.test.tsx new file mode 100644 index 00000000..6d15340c --- /dev/null +++ b/frontend/packages/form/src/ShuttleSelect.test.tsx @@ -0,0 +1,185 @@ +// ShuttleSelect — locks the two-pane render + selection behaviour +// (#627): which items live in which pane, click-to-move, search +// filter per pane, "Choose all" / "Remove all" buttons, order +// preservation when moving items in and out of Chosen. +import '@testing-library/jest-dom/vitest'; + +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { FieldChoice } from '@dar/data'; + +import { ShuttleSelect } from './ShuttleSelect'; + +const CHOICES: FieldChoice[] = [ + { value: 1, label: 'Engineering' }, + { value: 2, label: 'Marketing' }, + { value: 3, label: 'Finance' }, + { value: 4, label: 'Operations' }, +]; + +function paneByTitle(title: string): HTMLElement { + // Each Pane renders an `
      ` + // pointing to a `
      ` carrying the title text. + return screen.getByRole('listbox', { name: title }); +} + +describe('ShuttleSelect (#627)', () => { + it('renders two panes: Available (unselected) + Chosen (selected)', () => { + render( + {}} + />, + ); + const avail = paneByTitle('Available departments'); + const chosen = paneByTitle('Chosen departments'); + expect(within(avail).getByText('Engineering')).toBeInTheDocument(); + expect(within(avail).getByText('Finance')).toBeInTheDocument(); + expect(within(avail).getByText('Operations')).toBeInTheDocument(); + expect(within(avail).queryByText('Marketing')).not.toBeInTheDocument(); + expect(within(chosen).getByText('Marketing')).toBeInTheDocument(); + }); + + it('clicking an Available item appends it to Chosen (preserves order)', () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(within(paneByTitle('Available departments')).getByText('Finance')); + expect(onChange).toHaveBeenCalledWith([2, 3]); + }); + + it('clicking a Chosen item removes it', () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(within(paneByTitle('Chosen departments')).getByText('Marketing')); + expect(onChange).toHaveBeenCalledWith([1, 3]); + }); + + it('search filters items within a pane (case-insensitive)', () => { + render( + {}} + />, + ); + const availSearch = screen.getByLabelText('Filter Available departments'); + fireEvent.change(availSearch, { target: { value: 'fin' } }); + const avail = paneByTitle('Available departments'); + expect(within(avail).getByText('Finance')).toBeInTheDocument(); + expect(within(avail).queryByText('Engineering')).not.toBeInTheDocument(); + }); + + it('Choose all moves every FILTERED Available item to Chosen', () => { + const onChange = vi.fn(); + render( + , + ); + // Narrow the visible set to Engineering + Operations (anything with `o`). + const availSearch = screen.getByLabelText('Filter Available departments'); + fireEvent.change(availSearch, { target: { value: 'O' } }); + fireEvent.click(screen.getByRole('button', { name: 'Choose all' })); + // Marketing matches "O" too (mArketing has no O — but "ratiOns" / + // "engineering" / "operatiOns" do). Be explicit. + const want = CHOICES.filter((c) => c.label.toLowerCase().includes('o')).map((c) => c.value); + expect(onChange).toHaveBeenCalledWith(want); + }); + + it('Remove all clears every FILTERED Chosen item', () => { + const onChange = vi.fn(); + render( + , + ); + // Filter Chosen by "ing" — matches Engineering + Marketing only + // (Finance + Operations have no "ing"). Removes those two; the + // surviving Chosen set is [3, 4] in order. + const chosenSearch = screen.getByLabelText('Filter Chosen departments'); + fireEvent.change(chosenSearch, { target: { value: 'ing' } }); + fireEvent.click(screen.getByRole('button', { name: 'Remove all' })); + expect(onChange).toHaveBeenCalledWith([3, 4]); + }); + + it('horizontal vs vertical orientation toggles the grid layout', () => { + const { container, rerender } = render( + {}} + />, + ); + const hRoot = container.firstChild as HTMLElement; + expect(hRoot.className).toContain('sm:grid-cols-[1fr_1fr]'); + rerender( + {}} + />, + ); + const vRoot = container.firstChild as HTMLElement; + expect(vRoot.className).not.toContain('sm:grid-cols-[1fr_1fr]'); + }); + + it('round-trips numeric pks as numbers (not strings) on emit', () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(within(paneByTitle('Available departments')).getByText('Finance')); + const [emitted] = onChange.mock.calls[0] as [Array]; + expect(emitted).toEqual([1, 3]); + for (const v of emitted) expect(typeof v).toBe('number'); + }); +}); diff --git a/frontend/packages/form/src/ShuttleSelect.tsx b/frontend/packages/form/src/ShuttleSelect.tsx new file mode 100644 index 00000000..3cffc5d7 --- /dev/null +++ b/frontend/packages/form/src/ShuttleSelect.tsx @@ -0,0 +1,262 @@ +// ShuttleSelect — the two-pane "available / chosen" widget Django's +// HTML admin renders for ``filter_horizontal`` / ``filter_vertical`` +// M2M fields (#627). Fires when the API emits ``widget: "shuttle_h"`` +// or ``"shuttle_v"``; the SPA fell back to a single-list checkbox +// bank before, which doesn't scale past ~50 options. +// +// Contract: +// - ``choices`` — full set of options as ``{value, label}`` pairs. +// - ``value`` — the operator's current selection (array of choice +// values), order-preserved. +// - ``orientation`` — ``'h'`` side-by-side (filter_horizontal), ``'v'`` +// stacked (filter_vertical). +// - ``onChange`` — fires with the new ordered array of selected values. +// +// Behaviour: +// - Each pane has its own filter input that narrows the visible items +// client-side (no server roundtrip — the choices are already inlined). +// - Single click moves the item to the other pane (matches Django). +// - "Choose all" / "Remove all" links act on the FILTERED view, so +// typing a search term + clicking the link affects only those items. +// - Selection order is preserved: when an item is moved INTO chosen, it +// appends; when moved out, the relative order of remaining items is +// kept. Matches Django's admin. + +import { useId, useMemo, useState } from 'react'; + +import type { FieldChoice, WriteValue } from '@dar/data'; + +interface ShuttleSelectProps { + /** Stable id prefix for the search inputs (a11y labelling). */ + id: string; + /** Inlined choice list — the same shape as ``field.choices``. */ + choices: FieldChoice[]; + /** Currently selected values (array of choice values). */ + value: WriteValue; + /** ``filter_horizontal`` → ``'h'``; ``filter_vertical`` → ``'v'``. */ + orientation: 'h' | 'v'; + /** Label of the field (drives the "Available