Merge feature/ticket41-ticketpicker-multiselect into develop
Multi-sélection TicketPicker (#41). QA vert (tsc + 620 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -209,8 +209,8 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Add an existing ticket via the TicketPicker popup (#19):
|
||||
single-select, excluding tickets already in this sprint. */}
|
||||
{/* Add existing tickets via the TicketPicker popup (#19/#41):
|
||||
multi-select, excluding tickets already in this sprint. */}
|
||||
<div className="mt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
@ -223,7 +223,7 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
|
||||
>
|
||||
{addable.length === 0
|
||||
? "No tickets to add"
|
||||
: "Ajouter un ticket"}
|
||||
: "Ajouter des tickets"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -278,24 +278,28 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Add-ticket picker (#19) — mounts at floatingWindowNested (60), above
|
||||
this sprint dialog. Excludes tickets already in the target sprint. ── */}
|
||||
{/* ── Add-ticket picker (#19/#41) — mounts at floatingWindowNested (60),
|
||||
above this sprint dialog. Multi-select so several tickets can be added
|
||||
in one pass; excludes tickets already in the target sprint. ── */}
|
||||
<TicketPicker
|
||||
open={pickerSprint !== null}
|
||||
projectId={projectId}
|
||||
title={
|
||||
pickerSprint
|
||||
? `Ajouter un ticket au sprint « ${pickerSprint.name} »`
|
||||
: "Ajouter un ticket"
|
||||
? `Ajouter des tickets au sprint « ${pickerSprint.name} »`
|
||||
: "Ajouter des tickets"
|
||||
}
|
||||
selectionMode="single"
|
||||
confirmLabel="Ajouter au sprint"
|
||||
selectionMode="multi"
|
||||
excludeRefs={items
|
||||
.filter((t) => t.sprintId === pickerSprint?.id)
|
||||
.map((t) => t.ref)}
|
||||
onSelect={(result) => {
|
||||
const picked = Array.isArray(result) ? result[0] : result;
|
||||
if (picked && pickerSprint) {
|
||||
void vm.assignSprint(picked.ref, pickerSprint.id);
|
||||
const picked = Array.isArray(result) ? result : [result];
|
||||
if (pickerSprint) {
|
||||
for (const p of picked) {
|
||||
void vm.assignSprint(p.ref, pickerSprint.id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClose={() => setPickerSprint(null)}
|
||||
|
||||
@ -241,4 +241,108 @@ describe("TicketPicker (#18)", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
readSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe("multi-select (#41)", () => {
|
||||
it("toggles rows without closing and confirms the resolved array", async () => {
|
||||
const [a, , c] = await seedTrio(ticket);
|
||||
const { onSelect, onClose } = renderPicker(ticket, {
|
||||
selectionMode: "multi",
|
||||
confirmLabel: "Ajouter au sprint",
|
||||
});
|
||||
|
||||
await screen.findByText("Alpha bug");
|
||||
|
||||
const confirm = screen.getByLabelText(
|
||||
"confirm ticket selection",
|
||||
) as HTMLButtonElement;
|
||||
// Empty selection ⇒ confirm disabled, count reads 0.
|
||||
expect(confirm.disabled).toBe(true);
|
||||
expect(screen.getByText("0 sélectionné")).toBeTruthy();
|
||||
|
||||
// Select two rows; the picker stays open and does not fire onSelect yet.
|
||||
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
|
||||
fireEvent.click(screen.getByLabelText(`select ticket ${c.ref}`));
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
// Visual state: the toggled rows expose aria-checked.
|
||||
expect(
|
||||
screen.getByLabelText(`select ticket ${a.ref}`).getAttribute("aria-checked"),
|
||||
).toBe("true");
|
||||
expect(screen.getByText("2 sélectionnés")).toBeTruthy();
|
||||
expect(confirm.textContent).toContain("2");
|
||||
expect(confirm.disabled).toBe(false);
|
||||
|
||||
// Toggle one back off ⇒ count drops, still open.
|
||||
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
|
||||
expect(screen.getByText("1 sélectionné")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByLabelText(`select ticket ${a.ref}`).getAttribute("aria-checked"),
|
||||
).toBe("false");
|
||||
|
||||
// Re-select and confirm ⇒ onSelect receives the resolved array, then close.
|
||||
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
|
||||
fireEvent.click(confirm);
|
||||
|
||||
await waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
|
||||
const results = onSelect.mock.calls[0][0] as TicketPickerResult[];
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
expect(results.map((r) => r.ref).sort()).toEqual([a.ref, c.ref].sort());
|
||||
expect(results).toContainEqual(
|
||||
expect.objectContaining({ ref: a.ref, id: a.id, number: a.number }),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("confirm stays disabled when selection is empty", async () => {
|
||||
await seedTrio(ticket);
|
||||
const { onSelect } = renderPicker(ticket, { selectionMode: "multi" });
|
||||
await screen.findByText("Alpha bug");
|
||||
|
||||
const confirm = screen.getByLabelText(
|
||||
"confirm ticket selection",
|
||||
) as HTMLButtonElement;
|
||||
expect(confirm.disabled).toBe(true);
|
||||
// Clicking a disabled button is a no-op.
|
||||
fireEvent.click(confirm);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects excludeRefs and facet filters in multi mode", async () => {
|
||||
const [a, b] = await seedTrio(ticket);
|
||||
renderPicker(ticket, {
|
||||
selectionMode: "multi",
|
||||
excludeRefs: [a.ref],
|
||||
});
|
||||
|
||||
// Excluded ref is not selectable.
|
||||
await screen.findByText("Bravo task");
|
||||
expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull();
|
||||
expect(screen.getByLabelText(`select ticket ${b.ref}`)).toBeTruthy();
|
||||
|
||||
// Facets still narrow the list (Open ⇒ only Alpha, which is excluded ⇒ none).
|
||||
fireEvent.click(screen.getByLabelText("filter status Open"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("No matching tickets.")).toBeTruthy(),
|
||||
);
|
||||
});
|
||||
|
||||
it("closes on Escape and backdrop without confirming", async () => {
|
||||
const [a] = await seedTrio(ticket);
|
||||
const { onSelect, onClose } = renderPicker(ticket, {
|
||||
selectionMode: "multi",
|
||||
});
|
||||
await screen.findByText("Alpha bug");
|
||||
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
|
||||
const backdrop = screen.getByRole("dialog").parentElement as HTMLElement;
|
||||
fireEvent.mouseDown(backdrop);
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -20,9 +20,9 @@
|
||||
* (SprintManager, MemoryEditor, …). Only the design-system-level `zIndex.ts`
|
||||
* went into `shared/ui`.
|
||||
*
|
||||
* V1 implements single-select (a row click selects and closes). `"multi"` is in
|
||||
* the frozen prop contract but not required this sprint; the surrounding
|
||||
* structure keeps it extensible.
|
||||
* Single-select: a row click resolves and closes, handing back one result.
|
||||
* Multi-select (#41): a row click toggles a local selection set (the picker
|
||||
* stays open); a footer button confirms and hands back the resolved array.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@ -119,6 +119,10 @@ function TicketPickerBody({
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const [resolveError, setResolveError] = useState<string | null>(null);
|
||||
// Multi-select (#41): the locally-accumulated selection. Persists across
|
||||
// filter/search changes so the user can build a batch from several queries.
|
||||
const [selected, setSelected] = useState<Set<TicketRef>>(() => new Set());
|
||||
const isMulti = selectionMode === "multi";
|
||||
|
||||
// Focus-trap (G5): remember the previously-focused element, move focus into
|
||||
// the dialog on mount, keep Tab cycling inside it, and restore focus on close.
|
||||
@ -159,22 +163,50 @@ function TicketPickerBody({
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
function describeErr(e: unknown): string {
|
||||
return e && typeof e === "object" && "message" in e
|
||||
? String((e as { message: unknown }).message)
|
||||
: String(e);
|
||||
}
|
||||
|
||||
/** Multi-select: toggle a row in/out of the local selection (never closes). */
|
||||
function toggleSelected(ref: TicketRef) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(ref)) next.delete(ref);
|
||||
else next.add(ref);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/** Single-select: resolve the clicked row and hand it back, then close. */
|
||||
async function handlePick(ref: TicketRef) {
|
||||
if (selecting) return;
|
||||
setSelecting(true);
|
||||
setResolveError(null);
|
||||
try {
|
||||
const result = await vm.resolve(ref);
|
||||
// V1: single-select selects-and-closes. Multi (not required this sprint)
|
||||
// would accumulate a set and confirm via the footer button instead.
|
||||
onSelect(selectionMode === "multi" ? [result] : result);
|
||||
onSelect(result);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setResolveError(
|
||||
e && typeof e === "object" && "message" in e
|
||||
? String((e as { message: unknown }).message)
|
||||
: String(e),
|
||||
setResolveError(describeErr(e));
|
||||
setSelecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Multi-select: resolve every selected ref and hand back the array. */
|
||||
async function handleConfirm() {
|
||||
if (selecting || selected.size === 0) return;
|
||||
setSelecting(true);
|
||||
setResolveError(null);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
Array.from(selected).map((ref) => vm.resolve(ref)),
|
||||
);
|
||||
onSelect(results);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setResolveError(describeErr(e));
|
||||
setSelecting(false);
|
||||
}
|
||||
}
|
||||
@ -243,19 +275,40 @@ function TicketPickerBody({
|
||||
<p className="text-sm text-muted">No matching tickets.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{vm.rows.map((t) => (
|
||||
{vm.rows.map((t) => {
|
||||
const checked = selected.has(t.ref);
|
||||
return (
|
||||
<li key={t.ref} className="py-1.5 first:pt-0 last:pb-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`select ticket ${t.ref}`}
|
||||
{...(isMulti
|
||||
? { role: "checkbox", "aria-checked": checked }
|
||||
: {})}
|
||||
disabled={selecting}
|
||||
onClick={() => void handlePick(t.ref)}
|
||||
onClick={() =>
|
||||
isMulti ? toggleSelected(t.ref) : void handlePick(t.ref)
|
||||
}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
|
||||
"hover:bg-raised focus:bg-raised focus:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
isMulti && checked && "bg-raised",
|
||||
)}
|
||||
>
|
||||
{isMulti && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border text-[10px] font-bold",
|
||||
checked
|
||||
? "border-primary bg-primary text-on-primary"
|
||||
: "border-border text-transparent",
|
||||
)}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
<TicketRefBadge ticketRef={t.ref} className="mt-0.5" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
|
||||
{t.title}
|
||||
@ -266,7 +319,8 @@ function TicketPickerBody({
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@ -284,15 +338,34 @@ function TicketPickerBody({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer reserved for multi-select confirm (not wired in single V1). */}
|
||||
{selectionMode === "multi" && (
|
||||
<footer className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||
{/* ── Multi-select confirm footer (#41) — count + confirm; disabled
|
||||
while empty or resolving. Single-select has no footer. ── */}
|
||||
{isMulti && (
|
||||
<footer className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-4 py-3">
|
||||
<span className="text-xs text-muted" aria-live="polite">
|
||||
{selected.size} sélectionné{selected.size > 1 ? "s" : ""}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="cancel ticket selection"
|
||||
onClick={onClose}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button size="sm" disabled>
|
||||
{confirmLabel}
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label="confirm ticket selection"
|
||||
loading={selecting}
|
||||
disabled={selected.size === 0 || selecting}
|
||||
onClick={() => void handleConfirm()}
|
||||
>
|
||||
{selected.size > 0
|
||||
? `${confirmLabel} (${selected.size})`
|
||||
: confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -896,23 +896,26 @@ describe("SprintManager (#11)", () => {
|
||||
expect(fresh.title).toBe("Kept");
|
||||
});
|
||||
|
||||
it("adds a backlog ticket to a sprint via the TicketPicker popup (#19)", async () => {
|
||||
it("adds several backlog tickets to a sprint in one pass via the multi picker (#19/#41)", async () => {
|
||||
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Backlog item" });
|
||||
const t1 = await ticket.create(PROJECT_ID, { title: "Backlog item" });
|
||||
const t2 = await ticket.create(PROJECT_ID, { title: "Backlog item 2" });
|
||||
|
||||
const dialog = await openManager();
|
||||
|
||||
// Open the add-ticket picker for the Target sprint.
|
||||
// Open the add-ticket picker for the Target sprint (multi-select).
|
||||
fireEvent.click(
|
||||
await within(dialog).findByLabelText("add ticket to sprint Target"),
|
||||
);
|
||||
|
||||
// The picker popup lists the backlog ticket — select it.
|
||||
fireEvent.click(await screen.findByLabelText(`select ticket ${t.ref}`));
|
||||
// The picker toggles rows without assigning; confirm applies the batch.
|
||||
fireEvent.click(await screen.findByLabelText(`select ticket ${t1.ref}`));
|
||||
fireEvent.click(await screen.findByLabelText(`select ticket ${t2.ref}`));
|
||||
fireEvent.click(screen.getByLabelText("confirm ticket selection"));
|
||||
|
||||
await waitFor(async () => {
|
||||
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
||||
expect(fresh.sprintId).toBe("s1");
|
||||
expect((await ticket.read(PROJECT_ID, t1.ref)).sprintId).toBe("s1");
|
||||
expect((await ticket.read(PROJECT_ID, t2.ref)).sprintId).toBe("s1");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user