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/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); + }} />