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
7 changes: 5 additions & 2 deletions frontend/packages/api/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions frontend/packages/form/src/FieldInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = (
<ShuttleSelect
id={id}
choices={field.choices}
value={value}
orientation={field.widget === 'shuttle_v' ? 'v' : 'h'}
label={field.label}
onChange={(next) => 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
Expand Down
185 changes: 185 additions & 0 deletions frontend/packages/form/src/ShuttleSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -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 `<ul role="listbox" aria-labelledby="...">`
// pointing to a `<div>` carrying the title text.
return screen.getByRole('listbox', { name: title });
}

describe('ShuttleSelect (#627)', () => {
it('renders two panes: Available (unselected) + Chosen (selected)', () => {
render(
<ShuttleSelect
id="t"
choices={CHOICES}
value={[2]}
orientation="h"
label="departments"
onChange={() => {}}
/>,
);
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(
<ShuttleSelect
id="t"
choices={CHOICES}
value={[2]}
orientation="h"
label="departments"
onChange={onChange}
/>,
);
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(
<ShuttleSelect
id="t"
choices={CHOICES}
value={[1, 2, 3]}
orientation="h"
label="departments"
onChange={onChange}
/>,
);
fireEvent.click(within(paneByTitle('Chosen departments')).getByText('Marketing'));
expect(onChange).toHaveBeenCalledWith([1, 3]);
});

it('search filters items within a pane (case-insensitive)', () => {
render(
<ShuttleSelect
id="t"
choices={CHOICES}
value={[]}
orientation="h"
label="departments"
onChange={() => {}}
/>,
);
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(
<ShuttleSelect
id="t"
choices={CHOICES}
value={[]}
orientation="h"
label="departments"
onChange={onChange}
/>,
);
// 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(
<ShuttleSelect
id="t"
choices={CHOICES}
value={[1, 2, 3, 4]}
orientation="h"
label="departments"
onChange={onChange}
/>,
);
// 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(
<ShuttleSelect
id="t"
choices={CHOICES}
value={[]}
orientation="h"
label="departments"
onChange={() => {}}
/>,
);
const hRoot = container.firstChild as HTMLElement;
expect(hRoot.className).toContain('sm:grid-cols-[1fr_1fr]');
rerender(
<ShuttleSelect
id="t"
choices={CHOICES}
value={[]}
orientation="v"
label="departments"
onChange={() => {}}
/>,
);
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(
<ShuttleSelect
id="t"
choices={CHOICES}
value={[1]}
orientation="h"
label="departments"
onChange={onChange}
/>,
);
fireEvent.click(within(paneByTitle('Available departments')).getByText('Finance'));
const [emitted] = onChange.mock.calls[0] as [Array<string | number>];
expect(emitted).toEqual([1, 3]);
for (const v of emitted) expect(typeof v).toBe('number');
});
});
Loading
Loading