diff --git a/frontend/packages/api/src/contract.ts b/frontend/packages/api/src/contract.ts index 0bba0a9..8618e1d 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 289d6eb..fff1ecb 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 0000000..6d15340 --- /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 `