feat(tickets): filtres multi-critères par cases à cocher (backend + frontend)

Passe les filtres de la liste des tickets en sélection multiple (#12).

Backend Rust :
- IssueListFilter : statuses/priorities en Vec, filter_matches en OR intra-champ
  et AND inter-champs
- TicketListRequestDto en tableaux + from_request (parse/déduplication)
- MCP idea_ticket_list aligné sur le nouveau contrat

Frontend :
- TicketListQuery.statuses/priorities
- UI de cases à cocher, toggle/clear des filtres

Tests verts : issue_store (7), app-tauri --lib (59), mcp_server (24),
issue_usecases (6), sprint_usecases (6), frontend vitest (505), npm run build (exit 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 06:41:01 +02:00
parent 413db25f08
commit feeca3462c
11 changed files with 410 additions and 81 deletions

View File

@ -1951,9 +1951,13 @@ export class MockTicketGateway implements TicketGateway {
async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
const q = query ?? {};
const text = q.text?.trim().toLowerCase();
// Multi-select facets (ticket #12): OR within a facet, AND across facets; an
// empty/absent set means "no constraint" (all values pass).
const statuses = q.statuses ?? [];
const priorities = q.priorities ?? [];
const rows = [...this.projectTickets(projectId).values()]
.filter((t) => !q.status || t.status === q.status)
.filter((t) => !q.priority || t.priority === q.priority)
.filter((t) => statuses.length === 0 || statuses.includes(t.status))
.filter((t) => priorities.length === 0 || priorities.includes(t.priority))
.filter(
(t) =>
!q.assignedAgentId || t.assignedAgentIds.includes(q.assignedAgentId),

View File

@ -58,8 +58,16 @@ export class TauriTicketGateway implements TicketGateway {
}
async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
const { statuses, priorities, ...rest } = query ?? {};
// Multi-select facets (ticket #12): the backend expects `statuses`/
// `priorities` arrays (empty ⇒ no constraint on that facet).
return invoke<TicketList>("ticket_list", {
request: { projectId, ...(query ?? {}) },
request: {
projectId,
statuses: statuses ?? [],
priorities: priorities ?? [],
...rest,
},
});
}

View File

@ -9,12 +9,7 @@
import { useState } from "react";
import type {
Sprint,
TicketPriority,
TicketStatus,
TicketSummary,
} from "@/domain";
import type { Sprint, TicketPriority, TicketSummary } from "@/domain";
import { Button, Input, Panel, Spinner, cn } from "@/shared";
import { useTickets } from "./useTickets";
import { useProjectAgents } from "./useProjectAgents";
@ -165,45 +160,53 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
vm.setQuery({ ...vm.query, text: e.target.value || undefined })
}
/>
{/* Multi-select facets (ticket #12): OR within a facet, AND across; no
box ticked ⇒ that facet is unconstrained. */}
<fieldset
aria-label="filter by status"
className="flex flex-wrap items-center gap-x-3 gap-y-1"
>
<legend className="mr-1 text-xs font-medium text-muted">Statut</legend>
{TICKET_STATUSES.map((s) => (
<label
key={s}
className="flex cursor-pointer items-center gap-1.5 text-xs"
>
<input
type="checkbox"
aria-label={`filter status ${statusLabel(s)}`}
className="accent-primary"
checked={(vm.query.statuses ?? []).includes(s)}
onChange={() => vm.toggleStatus(s)}
/>
<StatusBadge status={s} />
</label>
))}
</fieldset>
<fieldset
aria-label="filter by priority"
className="flex flex-wrap items-center gap-x-3 gap-y-1"
>
<legend className="mr-1 text-xs font-medium text-muted">
Priorité
</legend>
{TICKET_PRIORITIES.map((p) => (
<label
key={p}
className="flex cursor-pointer items-center gap-1.5 text-xs"
>
<input
type="checkbox"
aria-label={`filter priority ${priorityLabel(p)}`}
className="accent-primary"
checked={(vm.query.priorities ?? []).includes(p)}
onChange={() => vm.togglePriority(p)}
/>
<PriorityBadge priority={p} />
</label>
))}
</fieldset>
<div className="flex flex-wrap items-center gap-2">
<select
aria-label="filter by status"
className={selectClass}
value={vm.query.status ?? ""}
onChange={(e) =>
vm.setQuery({
...vm.query,
status: (e.target.value || undefined) as TicketStatus | undefined,
})
}
>
<option value="">All statuses</option>
{TICKET_STATUSES.map((s) => (
<option key={s} value={s}>
{statusLabel(s)}
</option>
))}
</select>
<select
aria-label="filter by priority"
className={selectClass}
value={vm.query.priority ?? ""}
onChange={(e) =>
vm.setQuery({
...vm.query,
priority: (e.target.value || undefined) as
| TicketPriority
| undefined,
})
}
>
<option value="">All priorities</option>
{TICKET_PRIORITIES.map((p) => (
<option key={p} value={p}>
{priorityLabel(p)}
</option>
))}
</select>
<select
aria-label="filter by assignee"
className={selectClass}
@ -222,6 +225,17 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
</option>
))}
</select>
{((vm.query.statuses?.length ?? 0) > 0 ||
(vm.query.priorities?.length ?? 0) > 0) && (
<Button
size="sm"
variant="ghost"
aria-label="clear filters"
onClick={() => vm.clearFacets()}
>
Effacer
</Button>
)}
</div>
</div>

View File

@ -78,7 +78,7 @@ describe("MockTicketGateway", () => {
expect(events).toContain("issueCreated");
});
it("filters by status and searches text", async () => {
it("filters by statuses (OR within facet) and searches text (#12)", async () => {
await ticket.create(PROJECT_ID, { title: "alpha bug", status: "open" });
const t2 = await ticket.create(PROJECT_ID, { title: "beta task" });
await ticket.update(PROJECT_ID, t2.ref, {
@ -86,13 +86,55 @@ describe("MockTicketGateway", () => {
expectedVersion: t2.version,
});
const open = await ticket.list(PROJECT_ID, { status: "open" });
const open = await ticket.list(PROJECT_ID, { statuses: ["open"] });
expect(open.items.map((i) => i.ref)).toEqual(["#1"]);
// OR within the status facet: both values pass.
const either = await ticket.list(PROJECT_ID, {
statuses: ["open", "closed"],
});
expect(either.items.map((i) => i.ref).sort()).toEqual(["#1", "#2"]);
// An empty set ⇒ no status constraint (all pass).
const all = await ticket.list(PROJECT_ID, { statuses: [] });
expect(all.items).toHaveLength(2);
const search = await ticket.list(PROJECT_ID, { text: "beta" });
expect(search.items.map((i) => i.ref)).toEqual(["#2"]);
});
it("combines status and priority facets with AND (#12)", async () => {
await ticket.create(PROJECT_ID, {
title: "a",
status: "open",
priority: "high",
});
await ticket.create(PROJECT_ID, {
title: "b",
status: "open",
priority: "low",
});
await ticket.create(PROJECT_ID, {
title: "c",
status: "closed",
priority: "high",
});
// statuses:[open] AND priorities:[high] ⇒ only #1.
const both = await ticket.list(PROJECT_ID, {
statuses: ["open"],
priorities: ["high"],
});
expect(both.items.map((i) => i.ref)).toEqual(["#1"]);
// OR within status (open|closed) AND priorities:[high] ⇒ #1 and #3.
const mix = await ticket.list(PROJECT_ID, {
statuses: ["open", "closed"],
priorities: ["high"],
});
expect(mix.items.map((i) => i.ref).sort()).toEqual(["#1", "#3"]);
});
it("rejects a stale write with a version conflict", async () => {
const t = await ticket.create(PROJECT_ID, { title: "x" });
await ticket.update(PROJECT_ID, t.ref, {
@ -172,6 +214,59 @@ describe("TicketsView", () => {
expect(await screen.findByText("Live created")).toBeTruthy();
});
it("multi-selects status/priority facets (OR intra, AND inter) and clears (#12)", async () => {
const listSpy = vi.spyOn(ticket, "list");
await ticket.create(PROJECT_ID, {
title: "Alpha",
status: "open",
priority: "high",
});
await ticket.create(PROJECT_ID, {
title: "Bravo",
status: "closed",
priority: "low",
});
await ticket.create(PROJECT_ID, {
title: "Charlie",
status: "QA",
priority: "high",
});
renderView(ticket, system, agent);
// All three visible initially (no facet ⇒ all pass).
await screen.findByText("Alpha");
expect(screen.getByText("Bravo")).toBeTruthy();
expect(screen.getByText("Charlie")).toBeTruthy();
// Tick two statuses → OR within the facet ⇒ open|closed ⇒ Alpha + Bravo,
// Charlie (QA) drops out.
fireEvent.click(screen.getByLabelText("filter status Open"));
fireEvent.click(screen.getByLabelText("filter status Closed"));
await waitFor(() => expect(screen.queryByText("Charlie")).toBeNull());
expect(screen.getByText("Alpha")).toBeTruthy();
expect(screen.getByText("Bravo")).toBeTruthy();
// Add a priority → AND across facets ⇒ (open|closed) AND high ⇒ only Alpha.
fireEvent.click(screen.getByLabelText("filter priority High"));
await waitFor(() => expect(screen.queryByText("Bravo")).toBeNull());
expect(screen.getByText("Alpha")).toBeTruthy();
// The gateway received the expected multi-select query.
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]).toMatchObject({
statuses: ["open", "closed"],
priorities: ["high"],
}),
);
// « Effacer » empties both facets ⇒ all three back.
fireEvent.click(screen.getByLabelText("clear filters"));
await waitFor(() => expect(screen.getByText("Charlie")).toBeTruthy());
expect(screen.getByText("Bravo")).toBeTruthy();
expect(screen.getByText("Alpha")).toBeTruthy();
});
it("opens the detail and edits status with a version bump", async () => {
const t = await ticket.create(PROJECT_ID, { title: "Editable" });
renderView(ticket, system, agent);

View File

@ -20,6 +20,8 @@ import {
type Sprint,
type Ticket,
type TicketList,
type TicketPriority,
type TicketStatus,
} from "@/domain";
import type { CreateTicketInput, TicketListQuery } from "@/ports";
import { useGateways } from "@/app/di";
@ -32,6 +34,16 @@ export interface TicketsViewModel {
busy: boolean;
query: TicketListQuery;
setQuery: (next: TicketListQuery) => void;
/**
* Toggles a status in the multi-select filter (ticket #12): adds it when
* absent, removes it when present. An empty set ⇒ no status constraint (all).
* The list re-fetches automatically as the query changes.
*/
toggleStatus: (status: TicketStatus) => void;
/** Toggles a priority in the multi-select filter (ticket #12). */
togglePriority: (priority: TicketPriority) => void;
/** Clears both facet filters (status + priority), leaving other criteria. */
clearFacets: () => void;
refresh: () => Promise<void>;
create: (input: CreateTicketInput) => Promise<Ticket | null>;
/**
@ -89,6 +101,35 @@ export function useTickets(projectId: string): TicketsViewModel {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, ticket, queryKey]);
// Toggle a value in one facet set, dropping the key entirely when it empties
// so the query stays clean ("no constraint"). Setting a fresh object identity
// re-fetches via the `queryKey` effect (ticket #12).
const toggleStatus = useCallback((status: TicketStatus) => {
setQuery((prev) => {
const current = prev.statuses ?? [];
const next = current.includes(status)
? current.filter((s) => s !== status)
: [...current, status];
const { statuses: _drop, ...rest } = prev;
return next.length > 0 ? { ...rest, statuses: next } : rest;
});
}, []);
const togglePriority = useCallback((priority: TicketPriority) => {
setQuery((prev) => {
const current = prev.priorities ?? [];
const next = current.includes(priority)
? current.filter((p) => p !== priority)
: [...current, priority];
const { priorities: _drop, ...rest } = prev;
return next.length > 0 ? { ...rest, priorities: next } : rest;
});
}, []);
const clearFacets = useCallback(() => {
setQuery(({ statuses: _s, priorities: _p, ...rest }) => rest);
}, []);
const refreshSprints = useCallback(async () => {
try {
setSprints(await ticket.listSprints(projectId));
@ -242,6 +283,9 @@ export function useTickets(projectId: string): TicketsViewModel {
busy,
query,
setQuery,
toggleStatus,
togglePriority,
clearFacets,
refresh,
create,
assignSprint,

View File

@ -707,10 +707,16 @@ export interface ConversationGateway {
): Promise<TurnPage>;
}
/** Filter/search criteria for {@link TicketGateway.list}. */
/**
* Filter/search criteria for {@link TicketGateway.list} (ticket #12).
*
* Status and priority are **multi-select**: `statuses`/`priorities` are sets of
* accepted values with OR semantics *within* a facet and AND *across* facets. An
* empty or absent array means "no constraint on this facet" (all values pass).
*/
export interface TicketListQuery {
status?: TicketStatus;
priority?: TicketPriority;
statuses?: TicketStatus[];
priorities?: TicketPriority[];
assignedAgentId?: string;
/** Free-text match over title/description. */
text?: string;