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>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add an existing ticket via the TicketPicker popup (#19):
|
{/* Add existing tickets via the TicketPicker popup (#19/#41):
|
||||||
single-select, excluding tickets already in this sprint. */}
|
multi-select, excluding tickets already in this sprint. */}
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -223,7 +223,7 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
|
|||||||
>
|
>
|
||||||
{addable.length === 0
|
{addable.length === 0
|
||||||
? "No tickets to add"
|
? "No tickets to add"
|
||||||
: "Ajouter un ticket"}
|
: "Ajouter des tickets"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -278,24 +278,28 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Add-ticket picker (#19) — mounts at floatingWindowNested (60), above
|
{/* ── Add-ticket picker (#19/#41) — mounts at floatingWindowNested (60),
|
||||||
this sprint dialog. Excludes tickets already in the target sprint. ── */}
|
above this sprint dialog. Multi-select so several tickets can be added
|
||||||
|
in one pass; excludes tickets already in the target sprint. ── */}
|
||||||
<TicketPicker
|
<TicketPicker
|
||||||
open={pickerSprint !== null}
|
open={pickerSprint !== null}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
title={
|
title={
|
||||||
pickerSprint
|
pickerSprint
|
||||||
? `Ajouter un ticket au sprint « ${pickerSprint.name} »`
|
? `Ajouter des tickets au sprint « ${pickerSprint.name} »`
|
||||||
: "Ajouter un ticket"
|
: "Ajouter des tickets"
|
||||||
}
|
}
|
||||||
selectionMode="single"
|
confirmLabel="Ajouter au sprint"
|
||||||
|
selectionMode="multi"
|
||||||
excludeRefs={items
|
excludeRefs={items
|
||||||
.filter((t) => t.sprintId === pickerSprint?.id)
|
.filter((t) => t.sprintId === pickerSprint?.id)
|
||||||
.map((t) => t.ref)}
|
.map((t) => t.ref)}
|
||||||
onSelect={(result) => {
|
onSelect={(result) => {
|
||||||
const picked = Array.isArray(result) ? result[0] : result;
|
const picked = Array.isArray(result) ? result : [result];
|
||||||
if (picked && pickerSprint) {
|
if (pickerSprint) {
|
||||||
void vm.assignSprint(picked.ref, pickerSprint.id);
|
for (const p of picked) {
|
||||||
|
void vm.assignSprint(p.ref, pickerSprint.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClose={() => setPickerSprint(null)}
|
onClose={() => setPickerSprint(null)}
|
||||||
|
|||||||
@ -241,4 +241,108 @@ describe("TicketPicker (#18)", () => {
|
|||||||
expect(onClose).not.toHaveBeenCalled();
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
readSpy.mockRestore();
|
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`
|
* (SprintManager, MemoryEditor, …). Only the design-system-level `zIndex.ts`
|
||||||
* went into `shared/ui`.
|
* went into `shared/ui`.
|
||||||
*
|
*
|
||||||
* V1 implements single-select (a row click selects and closes). `"multi"` is in
|
* Single-select: a row click resolves and closes, handing back one result.
|
||||||
* the frozen prop contract but not required this sprint; the surrounding
|
* Multi-select (#41): a row click toggles a local selection set (the picker
|
||||||
* structure keeps it extensible.
|
* stays open); a footer button confirms and hands back the resolved array.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
@ -119,6 +119,10 @@ function TicketPickerBody({
|
|||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
const [selecting, setSelecting] = useState(false);
|
const [selecting, setSelecting] = useState(false);
|
||||||
const [resolveError, setResolveError] = useState<string | null>(null);
|
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
|
// 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.
|
// the dialog on mount, keep Tab cycling inside it, and restore focus on close.
|
||||||
@ -159,22 +163,50 @@ function TicketPickerBody({
|
|||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [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) {
|
async function handlePick(ref: TicketRef) {
|
||||||
if (selecting) return;
|
if (selecting) return;
|
||||||
setSelecting(true);
|
setSelecting(true);
|
||||||
setResolveError(null);
|
setResolveError(null);
|
||||||
try {
|
try {
|
||||||
const result = await vm.resolve(ref);
|
const result = await vm.resolve(ref);
|
||||||
// V1: single-select selects-and-closes. Multi (not required this sprint)
|
onSelect(result);
|
||||||
// would accumulate a set and confirm via the footer button instead.
|
|
||||||
onSelect(selectionMode === "multi" ? [result] : result);
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setResolveError(
|
setResolveError(describeErr(e));
|
||||||
e && typeof e === "object" && "message" in e
|
setSelecting(false);
|
||||||
? String((e as { message: unknown }).message)
|
}
|
||||||
: String(e),
|
}
|
||||||
|
|
||||||
|
/** 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);
|
setSelecting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -243,30 +275,52 @@ function TicketPickerBody({
|
|||||||
<p className="text-sm text-muted">No matching tickets.</p>
|
<p className="text-sm text-muted">No matching tickets.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col divide-y divide-border">
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
{vm.rows.map((t) => (
|
{vm.rows.map((t) => {
|
||||||
<li key={t.ref} className="py-1.5 first:pt-0 last:pb-0">
|
const checked = selected.has(t.ref);
|
||||||
<button
|
return (
|
||||||
type="button"
|
<li key={t.ref} className="py-1.5 first:pt-0 last:pb-0">
|
||||||
aria-label={`select ticket ${t.ref}`}
|
<button
|
||||||
disabled={selecting}
|
type="button"
|
||||||
onClick={() => void handlePick(t.ref)}
|
aria-label={`select ticket ${t.ref}`}
|
||||||
className={cn(
|
{...(isMulti
|
||||||
"flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
|
? { role: "checkbox", "aria-checked": checked }
|
||||||
"hover:bg-raised focus:bg-raised focus:outline-none",
|
: {})}
|
||||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
disabled={selecting}
|
||||||
)}
|
onClick={() =>
|
||||||
>
|
isMulti ? toggleSelected(t.ref) : void handlePick(t.ref)
|
||||||
<TicketRefBadge ticketRef={t.ref} className="mt-0.5" />
|
}
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
|
className={cn(
|
||||||
{t.title}
|
"flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
|
||||||
</span>
|
"hover:bg-raised focus:bg-raised focus:outline-none",
|
||||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
<StatusBadge status={t.status} />
|
isMulti && checked && "bg-raised",
|
||||||
<PriorityBadge priority={t.priority} />
|
)}
|
||||||
</span>
|
>
|
||||||
</button>
|
{isMulti && (
|
||||||
</li>
|
<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}
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<StatusBadge status={t.status} />
|
||||||
|
<PriorityBadge priority={t.priority} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -284,15 +338,34 @@ function TicketPickerBody({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer reserved for multi-select confirm (not wired in single V1). */}
|
{/* ── Multi-select confirm footer (#41) — count + confirm; disabled
|
||||||
{selectionMode === "multi" && (
|
while empty or resolving. Single-select has no footer. ── */}
|
||||||
<footer className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
|
{isMulti && (
|
||||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
<footer className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-4 py-3">
|
||||||
Annuler
|
<span className="text-xs text-muted" aria-live="polite">
|
||||||
</Button>
|
{selected.size} sélectionné{selected.size > 1 ? "s" : ""}
|
||||||
<Button size="sm" disabled>
|
</span>
|
||||||
{confirmLabel}
|
<div className="flex items-center gap-2">
|
||||||
</Button>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="cancel ticket selection"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<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>
|
</footer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -896,23 +896,26 @@ describe("SprintManager (#11)", () => {
|
|||||||
expect(fresh.title).toBe("Kept");
|
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" });
|
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();
|
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(
|
fireEvent.click(
|
||||||
await within(dialog).findByLabelText("add ticket to sprint Target"),
|
await within(dialog).findByLabelText("add ticket to sprint Target"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The picker popup lists the backlog ticket — select it.
|
// The picker toggles rows without assigning; confirm applies the batch.
|
||||||
fireEvent.click(await screen.findByLabelText(`select ticket ${t.ref}`));
|
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 () => {
|
await waitFor(async () => {
|
||||||
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
expect((await ticket.read(PROJECT_ID, t1.ref)).sprintId).toBe("s1");
|
||||||
expect(fresh.sprintId).toBe("s1");
|
expect((await ticket.read(PROJECT_ID, t2.ref)).sprintId).toBe("s1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user