From e523f444255500c26e03a7a437b1e0dac13c452c Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 7 Jul 2026 11:45:59 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(tickets):=20tri=20de=20la=20liste=20de?= =?UTF-8?q?=20tickets=20c=C3=B4t=C3=A9=20backend=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute le paramètre de tri dans TicketListQuery et l'expose via ticket_list (surface app-tauri + MCP). Ports et adaptateur mock frontend alignés sur le contrat de tri. La partie UI (contrôle de tri dans TicketFacetsBar) suivra dans un commit frontend dédié. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/tickets.rs | 120 +++++++++++++++++- .../src/orchestrator/mcp/tickets.rs | 10 ++ frontend/src/adapters/mock/index.ts | 48 ++++++- frontend/src/ports/index.ts | 11 ++ 4 files changed, 184 insertions(+), 5 deletions(-) diff --git a/crates/app-tauri/src/tickets.rs b/crates/app-tauri/src/tickets.rs index ef813c3..c0ed986 100644 --- a/crates/app-tauri/src/tickets.rs +++ b/crates/app-tauri/src/tickets.rs @@ -202,10 +202,37 @@ pub struct TicketListRequestDto { pub assigned_agent_id: Option, pub sprint_id: Option, pub text: Option, + pub sort: Option, pub limit: Option, pub cursor: Option, } +/// List sort request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketListSortDto { + pub field: TicketListSortFieldDto, + pub direction: TicketListSortDirectionDto, +} + +/// List sort field. +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TicketListSortFieldDto { + Number, + Priority, + Status, + Title, +} + +/// List sort direction. +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TicketListSortDirectionDto { + Asc, + Desc, +} + /// Update request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -410,7 +437,7 @@ impl TicketToolProvider for AppTicketToolProvider { } "idea_ticket_list" => { let req = mcp_list_request(project, arguments)?; - let rows = self + let mut rows = self .list .execute(ListIssuesInput { project: project.clone(), @@ -419,6 +446,7 @@ impl TicketToolProvider for AppTicketToolProvider { .await .map_err(ticket_error)? .issues; + sort_ticket_rows(&mut rows, req.sort); let sprints = self .list_sprints .execute(ListSprintsInput { @@ -684,7 +712,7 @@ pub async fn ticket_list( ) -> Result { let project = resolve_project(&state, &request.project_id).await?; let page = TicketListPageInput::from_request(request)?; - let rows = state + let mut rows = state .list_issues .execute(ListIssuesInput { project, @@ -693,6 +721,7 @@ pub async fn ticket_list( .await .map_err(ErrorDto::from)? .issues; + sort_ticket_rows(&mut rows, page.sort); Ok(paginate(rows, page.limit, page.cursor)) } @@ -1114,6 +1143,7 @@ impl From for TicketCarnetDto { #[derive(Debug)] struct TicketListPageInput { filter: IssueListFilter, + sort: Option, limit: usize, cursor: Option, } @@ -1136,12 +1166,59 @@ impl TicketListPageInput { .transpose()?, text: request.text, }, + sort: request.sort, limit: request.limit.unwrap_or(100).clamp(1, 500), cursor: request.cursor, }) } } +fn sort_ticket_rows(rows: &mut [IssueIndexEntry], sort: Option) { + let Some(sort) = sort else { + return; + }; + + rows.sort_by(|a, b| { + let field_order = match sort.field { + TicketListSortFieldDto::Number => { + a.issue_ref.number().get().cmp(&b.issue_ref.number().get()) + } + TicketListSortFieldDto::Priority => { + priority_rank(a.priority).cmp(&priority_rank(b.priority)) + } + TicketListSortFieldDto::Status => status_rank(a.status).cmp(&status_rank(b.status)), + TicketListSortFieldDto::Title => a + .title + .to_lowercase() + .cmp(&b.title.to_lowercase()) + .then_with(|| a.title.cmp(&b.title)), + }; + let directed = match sort.direction { + TicketListSortDirectionDto::Asc => field_order, + TicketListSortDirectionDto::Desc => field_order.reverse(), + }; + directed.then_with(|| a.issue_ref.number().get().cmp(&b.issue_ref.number().get())) + }); +} + +fn priority_rank(priority: IssuePriority) -> u8 { + match priority { + IssuePriority::Low => 0, + IssuePriority::Medium => 1, + IssuePriority::High => 2, + IssuePriority::Critical => 3, + } +} + +fn status_rank(status: IssueStatus) -> u8 { + match status { + IssueStatus::Open => 0, + IssueStatus::InProgress => 1, + IssueStatus::Qa => 2, + IssueStatus::Closed => 3, + } +} + async fn resolve_project(state: &AppState, project_id: &str) -> Result { let id = ProjectId::from_uuid( Uuid::parse_str(project_id) @@ -1556,6 +1633,7 @@ mod tests { assigned_agent_id: None, sprint_id: None, text: None, + sort: None, limit: None, cursor: None, }) @@ -1580,6 +1658,7 @@ mod tests { assigned_agent_id: None, sprint_id: None, text: None, + sort: None, limit: None, cursor: None, }) @@ -1598,6 +1677,7 @@ mod tests { assigned_agent_id: None, sprint_id: None, text: None, + sort: None, limit: Some(1), cursor: Some("1".into()), }) @@ -1619,6 +1699,42 @@ mod tests { assert_eq!(out.next_cursor, Some("2".to_owned())); } + #[test] + fn ticket_list_sort_priority_is_semantic_with_number_tie_breaker() { + let page = TicketListPageInput::from_request(TicketListRequestDto { + project_id: String::new(), + statuses: Vec::new(), + priorities: Vec::new(), + assigned_agent_id: None, + sprint_id: None, + text: None, + sort: Some(TicketListSortDto { + field: TicketListSortFieldDto::Priority, + direction: TicketListSortDirectionDto::Desc, + }), + limit: None, + cursor: None, + }) + .unwrap(); + let mut rows = vec![ + issue_row(4, IssueStatus::Open, IssuePriority::High, "Delta"), + issue_row(2, IssueStatus::Closed, IssuePriority::Critical, "Beta"), + issue_row(3, IssueStatus::Qa, IssuePriority::High, "Gamma"), + issue_row(1, IssueStatus::Open, IssuePriority::Low, "Alpha"), + ]; + + sort_ticket_rows(&mut rows, page.sort); + let out = paginate(rows, page.limit, page.cursor); + + assert_eq!( + out.items + .into_iter() + .map(|item| item.r#ref) + .collect::>(), + vec!["#2", "#3", "#4", "#1"] + ); + } + fn issue_row( number: u64, status: IssueStatus, diff --git a/crates/infrastructure/src/orchestrator/mcp/tickets.rs b/crates/infrastructure/src/orchestrator/mcp/tickets.rs index c6f9e6b..31d1073 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tickets.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tickets.rs @@ -74,6 +74,15 @@ pub fn catalogue() -> Vec { let ticket_ref = json!({ "type": "string", "pattern": "^#[1-9][0-9]*$" }); let status = json!({ "type": "string", "enum": ["open", "inProgress", "QA", "closed"] }); let priority = json!({ "type": "string", "enum": ["low", "medium", "high", "critical"] }); + let sort = json!({ + "type": "object", + "properties": { + "field": { "type": "string", "enum": ["number", "priority", "status", "title"] }, + "direction": { "type": "string", "enum": ["asc", "desc"] } + }, + "required": ["field", "direction"], + "additionalProperties": false + }); let link_kind = json!({ "type": "string", "enum": ["relatesTo", "blocks", "blockedBy", "duplicates", "dependsOn"] @@ -137,6 +146,7 @@ pub fn catalogue() -> Vec { }, "assignedAgentId": { "type": "string", "format": "uuid" }, "text": { "type": "string" }, + "sort": sort.clone(), "limit": { "type": "integer", "minimum": 1 }, "cursor": { "type": "string" } }, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 4f9115f..dc04083 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -46,6 +46,8 @@ import type { TicketLink, TicketLinkKind, TicketList, + TicketPriority, + TicketStatus, TurnPage, TurnView, Unsubscribe, @@ -87,6 +89,44 @@ import type { import { normalizeProjectWorkState } from "../workStateNormalization"; import { applyOperation, singleLeafTree } from "@/features/layout/layout"; +const TICKET_PRIORITY_RANK: Record = { + low: 0, + medium: 1, + high: 2, + critical: 3, +}; + +const TICKET_STATUS_RANK: Record = { + open: 0, + inProgress: 1, + QA: 2, + closed: 3, +}; + +function sortTickets( + rows: Ticket[], + sort: NonNullable, +): Ticket[] { + return rows.sort((a, b) => { + let fieldOrder = 0; + if (sort.field === "number") { + fieldOrder = a.number - b.number; + } else if (sort.field === "priority") { + fieldOrder = + TICKET_PRIORITY_RANK[a.priority] - TICKET_PRIORITY_RANK[b.priority]; + } else if (sort.field === "status") { + fieldOrder = TICKET_STATUS_RANK[a.status] - TICKET_STATUS_RANK[b.status]; + } else { + fieldOrder = + a.title.localeCompare(b.title, undefined, { sensitivity: "base" }) || + a.title.localeCompare(b.title); + } + + const directed = sort.direction === "desc" ? -fieldOrder : fieldOrder; + return directed || a.number - b.number; + }); +} + export class MockSystemGateway implements SystemGateway { private listeners = new Set<(e: DomainEvent) => void>(); @@ -1955,7 +1995,7 @@ export class MockTicketGateway implements TicketGateway { // empty/absent set means "no constraint" (all values pass). const statuses = q.statuses ?? []; const priorities = q.priorities ?? []; - const rows = [...this.projectTickets(projectId).values()] + let rows = [...this.projectTickets(projectId).values()] .filter((t) => statuses.length === 0 || statuses.includes(t.status)) .filter((t) => priorities.length === 0 || priorities.includes(t.priority)) .filter( @@ -1967,8 +2007,10 @@ export class MockTicketGateway implements TicketGateway { !text || t.title.toLowerCase().includes(text) || t.description.toLowerCase().includes(text), - ) - .sort((a, b) => b.number - a.number); + ); + rows = q.sort + ? sortTickets(rows, q.sort) + : rows.sort((a, b) => b.number - a.number); const start = Number.parseInt(q.cursor ?? "0", 10) || 0; const limit = Math.min(Math.max(q.limit ?? 100, 1), 500); const end = Math.min(rows.length, start + limit); diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 0c9d597..00fd5ef 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -713,6 +713,7 @@ export interface ConversationGateway { * 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). + * `sort` is optional; when absent, the backend preserves its historical order. */ export interface TicketListQuery { statuses?: TicketStatus[]; @@ -720,10 +721,20 @@ export interface TicketListQuery { assignedAgentId?: string; /** Free-text match over title/description. */ text?: string; + sort?: TicketListSort; limit?: number; cursor?: string; } +export type TicketListSortField = "number" | "priority" | "status" | "title"; + +export type TicketListSortDirection = "asc" | "desc"; + +export interface TicketListSort { + field: TicketListSortField; + direction: TicketListSortDirection; +} + /** Input for {@link TicketGateway.create}. */ export interface CreateTicketInput { title: string; From bd73fd79bd3863b8df968f7f5dd18ef37d889a1d Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 7 Jul 2026 11:54:28 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(tickets):=20contr=C3=B4le=20=C2=AB=20T?= =?UTF-8?q?rier=20par=E2=80=A6=20=C2=BB=20dans=20la=20barre=20de=20facette?= =?UTF-8?q?s=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute le sélecteur de tri dans TicketFacetsBar et propage le critère via useTicketSearch jusqu'à TicketsPanel et TicketPicker, sur le contrat de tri backend (e523f44). Couvre le tri par les tests tickets et TicketPicker. Co-Authored-By: Claude Opus 4.8 --- .../src/features/tickets/TicketFacetsBar.tsx | 74 ++++++++++++++++++- .../features/tickets/TicketPicker.test.tsx | 43 +++++++++++ .../src/features/tickets/TicketPicker.tsx | 2 + .../src/features/tickets/TicketsPanel.tsx | 5 ++ .../src/features/tickets/tickets.test.tsx | 42 +++++++++++ .../src/features/tickets/useTicketSearch.ts | 18 ++++- 6 files changed, 179 insertions(+), 5 deletions(-) diff --git a/frontend/src/features/tickets/TicketFacetsBar.tsx b/frontend/src/features/tickets/TicketFacetsBar.tsx index f09811e..9ef4608 100644 --- a/frontend/src/features/tickets/TicketFacetsBar.tsx +++ b/frontend/src/features/tickets/TicketFacetsBar.tsx @@ -14,7 +14,8 @@ */ import type { TicketPriority, TicketStatus } from "@/domain"; -import { Button, Input } from "@/shared"; +import type { TicketListSort, TicketListSortField } from "@/ports"; +import { Button, Input, cn } from "@/shared"; import { PriorityBadge, StatusBadge, @@ -24,6 +25,23 @@ import { statusLabel, } from "./ticketMeta"; +/** Styling for the sort-field select, matching the panel's other selects. */ +const selectClass = cn( + "h-8 rounded-md bg-raised px-2 text-xs text-content", + "border border-border outline-none transition-colors", + "focus:border-primary disabled:cursor-not-allowed disabled:opacity-50", +); + +/** Human labels for the sort fields (also the option text). */ +const SORT_FIELD_LABEL: Record = { + number: "Numéro", + priority: "Priorité", + status: "Statut", + title: "Titre", +}; + +const SORT_FIELDS = Object.keys(SORT_FIELD_LABEL) as TicketListSortField[]; + export interface TicketFacetsBarProps { /** Current free-text search value (controlled). */ text: string; @@ -42,6 +60,13 @@ export interface TicketFacetsBarProps { onClearFacets?: () => void; /** Placeholder for the search input. */ searchPlaceholder?: string; + /** + * Current sort (ticket #21). `undefined` ⇒ the backend's historical order. + * When {@link onSortChange} is provided, a « Trier par… » control appears. + */ + sort?: TicketListSort; + /** Emitted with the next sort, or `undefined` to restore the default order. */ + onSortChange?: (sort: TicketListSort | undefined) => void; } export function TicketFacetsBar({ @@ -53,6 +78,8 @@ export function TicketFacetsBar({ onTogglePriority, onClearFacets, searchPlaceholder = "Search title/description…", + sort, + onSortChange, }: TicketFacetsBarProps) { const hasFacets = statuses.length > 0 || priorities.length > 0; return ( @@ -109,6 +136,51 @@ export function TicketFacetsBar({ ))} + {/* Sort control (#21): field choice + asc/desc toggle. Absent field ⇒ + the backend keeps its historical order. Grouping-by-sprint stays + client-side; this sort applies intra-group. */} + {onSortChange && ( +
+ + {sort && ( + + )} +
+ )} {onClearFacets && hasFacets && (
diff --git a/frontend/src/features/tickets/TicketsPanel.tsx b/frontend/src/features/tickets/TicketsPanel.tsx index 65b0d19..abff0a8 100644 --- a/frontend/src/features/tickets/TicketsPanel.tsx +++ b/frontend/src/features/tickets/TicketsPanel.tsx @@ -163,6 +163,11 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { onToggleStatus={vm.toggleStatus} onTogglePriority={vm.togglePriority} onClearFacets={vm.clearFacets} + sort={vm.query.sort} + onSortChange={(sort) => { + const { sort: _drop, ...rest } = vm.query; + vm.setQuery(sort ? { ...rest, sort } : rest); + }} />