diff --git a/crates/app-tauri/src/tickets.rs b/crates/app-tauri/src/tickets.rs index eca279d..ef813c3 100644 --- a/crates/app-tauri/src/tickets.rs +++ b/crates/app-tauri/src/tickets.rs @@ -195,8 +195,10 @@ pub struct TicketDeleteRequestDto { pub struct TicketListRequestDto { #[serde(default)] pub project_id: String, - pub status: Option, - pub priority: Option, + #[serde(default)] + pub statuses: Vec, + #[serde(default)] + pub priorities: Vec, pub assigned_agent_id: Option, pub sprint_id: Option, pub text: Option, @@ -1109,6 +1111,7 @@ impl From for TicketCarnetDto { } } +#[derive(Debug)] struct TicketListPageInput { filter: IssueListFilter, limit: usize, @@ -1119,16 +1122,8 @@ impl TicketListPageInput { fn from_request(request: TicketListRequestDto) -> Result { Ok(Self { filter: IssueListFilter { - status: request - .status - .as_deref() - .map(parse_status_dto) - .transpose()?, - priority: request - .priority - .as_deref() - .map(parse_priority_dto) - .transpose()?, + statuses: parse_statuses_dto(request.statuses)?, + priorities: parse_priorities_dto(request.priorities)?, assigned_agent_id: request .assigned_agent_id .as_deref() @@ -1349,6 +1344,28 @@ fn parse_priority_dto(raw: &str) -> Result { parse_priority(raw).map_err(|e| ErrorDto::invalid(e.message)) } +fn parse_statuses_dto(raw: Vec) -> Result, ErrorDto> { + let mut out = Vec::new(); + for item in raw { + let status = parse_status_dto(&item)?; + if !out.contains(&status) { + out.push(status); + } + } + Ok(out) +} + +fn parse_priorities_dto(raw: Vec) -> Result, ErrorDto> { + let mut out = Vec::new(); + for item in raw { + let priority = parse_priority_dto(&item)?; + if !out.contains(&priority) { + out.push(priority); + } + } + Ok(out) +} + fn parse_link_kind_dto(raw: &str) -> Result { parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message)) } @@ -1524,3 +1541,99 @@ impl ErrorDto { } } } + +#[cfg(test)] +mod tests { + use super::*; + use domain::IssueNumber; + + #[test] + fn ticket_list_request_deduplicates_multi_select_filters() { + let page = TicketListPageInput::from_request(TicketListRequestDto { + project_id: String::new(), + statuses: vec!["open".into(), "closed".into(), "open".into()], + priorities: vec!["high".into(), "low".into(), "high".into()], + assigned_agent_id: None, + sprint_id: None, + text: None, + limit: None, + cursor: None, + }) + .unwrap(); + + assert_eq!( + page.filter.statuses, + vec![IssueStatus::Open, IssueStatus::Closed] + ); + assert_eq!( + page.filter.priorities, + vec![IssuePriority::High, IssuePriority::Low] + ); + } + + #[test] + fn ticket_list_request_rejects_invalid_multi_select_token() { + let err = TicketListPageInput::from_request(TicketListRequestDto { + project_id: String::new(), + statuses: vec!["open".into(), "bad".into()], + priorities: Vec::new(), + assigned_agent_id: None, + sprint_id: None, + text: None, + limit: None, + cursor: None, + }) + .unwrap_err(); + + assert_eq!(err.code, "INVALID"); + assert!(err.message.contains("invalid ticket status: bad")); + } + + #[test] + fn ticket_list_pagination_preserves_multi_filter_request_shape() { + let page = TicketListPageInput::from_request(TicketListRequestDto { + project_id: String::new(), + statuses: vec!["open".into(), "QA".into()], + priorities: vec!["high".into()], + assigned_agent_id: None, + sprint_id: None, + text: None, + limit: Some(1), + cursor: Some("1".into()), + }) + .unwrap(); + let rows = vec![ + issue_row(1, IssueStatus::Open, IssuePriority::High, "Alpha"), + issue_row(2, IssueStatus::Qa, IssuePriority::High, "Beta"), + issue_row(3, IssueStatus::Qa, IssuePriority::High, "Gamma"), + ]; + + assert_eq!( + page.filter.statuses, + vec![IssueStatus::Open, IssueStatus::Qa] + ); + assert_eq!(page.filter.priorities, vec![IssuePriority::High]); + let out = paginate(rows, page.limit, page.cursor); + assert_eq!(out.items.len(), 1); + assert_eq!(out.items[0].r#ref, "#2"); + assert_eq!(out.next_cursor, Some("2".to_owned())); + } + + fn issue_row( + number: u64, + status: IssueStatus, + priority: IssuePriority, + title: &str, + ) -> IssueIndexEntry { + IssueIndexEntry { + issue_ref: IssueRef::from(IssueNumber::new(number).unwrap()), + path: number.to_string(), + title: title.to_owned(), + status, + priority, + sprint: None, + assigned_agent_ids: Vec::new(), + updated_at: number, + } + } +} diff --git a/crates/domain/src/issue.rs b/crates/domain/src/issue.rs index 75cd342..c64c3a6 100644 --- a/crates/domain/src/issue.rs +++ b/crates/domain/src/issue.rs @@ -426,10 +426,10 @@ impl From<&Issue> for IssueIndexEntry { /// Store-side list filter. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct IssueListFilter { - /// Optional status filter. - pub status: Option, - /// Optional priority filter. - pub priority: Option, + /// Allowed statuses. Empty means every status. + pub statuses: Vec, + /// Allowed priorities. Empty means every priority. + pub priorities: Vec, /// Optional assigned agent filter. pub assigned_agent_id: Option, /// Optional sprint membership filter. diff --git a/crates/infrastructure/src/issues.rs b/crates/infrastructure/src/issues.rs index 6189a26..28c73f8 100644 --- a/crates/infrastructure/src/issues.rs +++ b/crates/infrastructure/src/issues.rs @@ -231,13 +231,10 @@ async fn write_index(root: &ProjectPath, rows: &[IssueIndexEntry]) -> Result<(), } fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool { - if filter.status.is_some_and(|status| row.status != status) { + if !filter.statuses.is_empty() && !filter.statuses.contains(&row.status) { return false; } - if filter - .priority - .is_some_and(|priority| row.priority != priority) - { + if !filter.priorities.is_empty() && !filter.priorities.contains(&row.priority) { return false; } if let Some(agent_id) = filter.assigned_agent_id { diff --git a/crates/infrastructure/src/orchestrator/mcp/tickets.rs b/crates/infrastructure/src/orchestrator/mcp/tickets.rs index d98e4bf..c6f9e6b 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tickets.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tickets.rs @@ -125,8 +125,16 @@ pub fn catalogue() -> Vec { input_schema: json!({ "type": "object", "properties": { - "status": status.clone(), - "priority": priority.clone(), + "statuses": { + "type": "array", + "items": status.clone(), + "uniqueItems": true + }, + "priorities": { + "type": "array", + "items": priority.clone(), + "uniqueItems": true + }, "assignedAgentId": { "type": "string", "format": "uuid" }, "text": { "type": "string" }, "limit": { "type": "integer", "minimum": 1 }, diff --git a/crates/infrastructure/tests/issue_store.rs b/crates/infrastructure/tests/issue_store.rs index 1744f5e..393d763 100644 --- a/crates/infrastructure/tests/issue_store.rs +++ b/crates/infrastructure/tests/issue_store.rs @@ -112,7 +112,7 @@ async fn issue_store_update_checks_expected_version() { } #[tokio::test] -async fn issue_store_lists_by_index_filters() { +async fn issue_store_lists_by_index_filters_with_empty_or_and_and_semantics() { let tmp = TempDir::new(); let root = tmp.root(); let store = FsIssueStore::new(); @@ -127,21 +127,61 @@ async fn issue_store_lists_by_index_filters() { }) .unwrap(); store.create(&root, &closed).await.unwrap(); + let qa_high = issue(&root, 3, "Gamma") + .mutate(IssueActor::System, 3_000, |i| { + i.status = IssueStatus::Qa; + i.priority = IssuePriority::Critical; + }) + .unwrap(); + store.create(&root, &qa_high).await.unwrap(); - let rows = store + let all = store.list(&root, IssueListFilter::default()).await.unwrap(); + assert_eq!(all.len(), 3); + + let by_status_or = store .list( &root, IssueListFilter { - status: Some(IssueStatus::Closed), - priority: Some(IssuePriority::Low), + statuses: vec![IssueStatus::Closed, IssueStatus::Qa], ..IssueListFilter::default() }, ) .await .unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].title, "Beta"); + assert_eq!(by_status_or.len(), 2); + assert_eq!(by_status_or[0].title, "Beta"); + assert_eq!(by_status_or[1].title, "Gamma"); + + let by_priority_or = store + .list( + &root, + IssueListFilter { + priorities: vec![IssuePriority::Low, IssuePriority::Critical], + ..IssueListFilter::default() + }, + ) + .await + .unwrap(); + + assert_eq!(by_priority_or.len(), 2); + assert_eq!(by_priority_or[0].title, "Beta"); + assert_eq!(by_priority_or[1].title, "Gamma"); + + let by_status_and_priority = store + .list( + &root, + IssueListFilter { + statuses: vec![IssueStatus::Closed, IssueStatus::Qa], + priorities: vec![IssuePriority::Low], + ..IssueListFilter::default() + }, + ) + .await + .unwrap(); + + assert_eq!(by_status_and_priority.len(), 1); + assert_eq!(by_status_and_priority[0].title, "Beta"); } #[tokio::test] diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index eae1160..4f9115f 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1951,9 +1951,13 @@ export class MockTicketGateway implements TicketGateway { async list(projectId: string, query?: TicketListQuery): Promise { 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), diff --git a/frontend/src/adapters/ticket.ts b/frontend/src/adapters/ticket.ts index 1357ac2..625ab01 100644 --- a/frontend/src/adapters/ticket.ts +++ b/frontend/src/adapters/ticket.ts @@ -58,8 +58,16 @@ export class TauriTicketGateway implements TicketGateway { } async list(projectId: string, query?: TicketListQuery): Promise { + const { statuses, priorities, ...rest } = query ?? {}; + // Multi-select facets (ticket #12): the backend expects `statuses`/ + // `priorities` arrays (empty ⇒ no constraint on that facet). return invoke("ticket_list", { - request: { projectId, ...(query ?? {}) }, + request: { + projectId, + statuses: statuses ?? [], + priorities: priorities ?? [], + ...rest, + }, }); } diff --git a/frontend/src/features/tickets/TicketsPanel.tsx b/frontend/src/features/tickets/TicketsPanel.tsx index 3373edf..c6dfed8 100644 --- a/frontend/src/features/tickets/TicketsPanel.tsx +++ b/frontend/src/features/tickets/TicketsPanel.tsx @@ -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. */} +
+ Statut + {TICKET_STATUSES.map((s) => ( + + ))} +
+
+ + Priorité + + {TICKET_PRIORITIES.map((p) => ( + + ))} +
- - + {((vm.query.statuses?.length ?? 0) > 0 || + (vm.query.priorities?.length ?? 0) > 0) && ( + + )}
diff --git a/frontend/src/features/tickets/tickets.test.tsx b/frontend/src/features/tickets/tickets.test.tsx index 54cd40d..ae5ef44 100644 --- a/frontend/src/features/tickets/tickets.test.tsx +++ b/frontend/src/features/tickets/tickets.test.tsx @@ -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); diff --git a/frontend/src/features/tickets/useTickets.ts b/frontend/src/features/tickets/useTickets.ts index 2bf7945..624c1d6 100644 --- a/frontend/src/features/tickets/useTickets.ts +++ b/frontend/src/features/tickets/useTickets.ts @@ -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; create: (input: CreateTicketInput) => Promise; /** @@ -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, diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 9848d62..0c9d597 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -707,10 +707,16 @@ export interface ConversationGateway { ): Promise; } -/** 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;