Merge feature/ticket21-sort-tickets into develop (#21)

Tri de la liste de tickets — backend (TicketListQuery/ticket_list, MCP)
+ frontend (contrôle « Trier par… » dans TicketFacetsBar). QA vert :
app-tauri 60+15, infrastructure 254, frontend typecheck + vitest 533/533.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 11:54:44 +02:00
10 changed files with 363 additions and 10 deletions

View File

@ -202,10 +202,37 @@ pub struct TicketListRequestDto {
pub assigned_agent_id: Option<String>,
pub sprint_id: Option<String>,
pub text: Option<String>,
pub sort: Option<TicketListSortDto>,
pub limit: Option<usize>,
pub cursor: Option<String>,
}
/// 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<TicketListDto, ErrorDto> {
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<IssueCarnet> for TicketCarnetDto {
#[derive(Debug)]
struct TicketListPageInput {
filter: IssueListFilter,
sort: Option<TicketListSortDto>,
limit: usize,
cursor: Option<String>,
}
@ -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<TicketListSortDto>) {
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<Project, ErrorDto> {
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<_>>(),
vec!["#2", "#3", "#4", "#1"]
);
}
fn issue_row(
number: u64,
status: IssueStatus,

View File

@ -74,6 +74,15 @@ pub fn catalogue() -> Vec<ToolDef> {
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<ToolDef> {
},
"assignedAgentId": { "type": "string", "format": "uuid" },
"text": { "type": "string" },
"sort": sort.clone(),
"limit": { "type": "integer", "minimum": 1 },
"cursor": { "type": "string" }
},

View File

@ -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<TicketPriority, number> = {
low: 0,
medium: 1,
high: 2,
critical: 3,
};
const TICKET_STATUS_RANK: Record<TicketStatus, number> = {
open: 0,
inProgress: 1,
QA: 2,
closed: 3,
};
function sortTickets(
rows: Ticket[],
sort: NonNullable<TicketListQuery["sort"]>,
): 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);

View File

@ -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<TicketListSortField, string> = {
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({
</label>
))}
</fieldset>
{/* 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 && (
<div
aria-label="sort tickets"
className="flex flex-wrap items-center gap-2"
>
<label className="flex items-center gap-1.5 text-xs text-muted">
Trier par
<select
aria-label="sort tickets by"
className={selectClass}
value={sort?.field ?? ""}
onChange={(e) => {
const field = e.target.value as TicketListSortField | "";
if (field === "") onSortChange(undefined);
else onSortChange({ field, direction: sort?.direction ?? "asc" });
}}
>
<option value="">Par défaut</option>
{SORT_FIELDS.map((f) => (
<option key={f} value={f}>
{SORT_FIELD_LABEL[f]}
</option>
))}
</select>
</label>
{sort && (
<Button
size="sm"
variant="ghost"
aria-label={`sort direction ${sort.direction === "asc" ? "ascending" : "descending"}`}
onClick={() =>
onSortChange({
field: sort.field,
direction: sort.direction === "asc" ? "desc" : "asc",
})
}
>
{sort.direction === "asc" ? "Croissant ↑" : "Décroissant ↓"}
</Button>
)}
</div>
)}
{onClearFacets && hasFacets && (
<div>
<Button

View File

@ -137,6 +137,49 @@ describe("TicketPicker (#18)", () => {
expect(screen.getByText("Bravo task")).toBeTruthy();
});
it("sorts the results via the « Trier par… » control (#21)", async () => {
const listSpy = vi.spyOn(ticket, "list");
await seedTrio(ticket);
renderPicker(ticket);
await screen.findByText("Alpha bug");
// The sort control is present in the picker too (shared TicketFacetsBar).
const sortBy = screen.getByLabelText("sort tickets by");
fireEvent.change(sortBy, { target: { value: "title" } });
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
field: "title",
direction: "asc",
}),
);
// Ascending by title ⇒ Alpha before Charlie in the DOM.
await waitFor(() => {
const rendered = screen
.getAllByText(/Alpha bug|Bravo task|Charlie chore/)
.map((n) => n.textContent);
expect(rendered.indexOf("Alpha bug")).toBeLessThan(
rendered.indexOf("Charlie chore"),
);
});
// Flip to descending ⇒ Charlie now precedes Alpha.
fireEvent.click(screen.getByLabelText("sort direction ascending"));
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
field: "title",
direction: "desc",
}),
);
await waitFor(() => {
const rendered = screen
.getAllByText(/Alpha bug|Bravo task|Charlie chore/)
.map((n) => n.textContent);
expect(rendered.indexOf("Charlie chore")).toBeLessThan(
rendered.indexOf("Alpha bug"),
);
});
});
it("debounced text search narrows the list", async () => {
await seedTrio(ticket);
renderPicker(ticket);

View File

@ -218,6 +218,8 @@ function TicketPickerBody({
onToggleStatus={vm.toggleStatus}
onTogglePriority={vm.togglePriority}
onClearFacets={vm.clearFacets}
sort={vm.sort}
onSortChange={vm.setSort}
/>
</div>

View File

@ -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);
}}
/>
<div className="flex flex-wrap items-center gap-2">
<select

View File

@ -267,6 +267,48 @@ describe("TicketsView", () => {
expect(screen.getByText("Alpha")).toBeTruthy();
});
it("sorts via the « Trier par… » control: relays sort + toggles direction (#21)", async () => {
const listSpy = vi.spyOn(ticket, "list");
await ticket.create(PROJECT_ID, { title: "Alpha" });
await ticket.create(PROJECT_ID, { title: "Bravo" });
renderView(ticket, system, agent);
await screen.findByText("Alpha");
// No sort chosen ⇒ the gateway sees no `sort` (historical order preserved).
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined();
// Choose a field ⇒ ascending by default, relayed in the query.
fireEvent.change(screen.getByLabelText("sort tickets by"), {
target: { value: "title" },
});
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
field: "title",
direction: "asc",
}),
);
// The direction toggle flips asc → desc.
fireEvent.click(
screen.getByLabelText("sort direction ascending"),
);
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
field: "title",
direction: "desc",
}),
);
// Back to « Par défaut » ⇒ `sort` dropped from the query again.
fireEvent.change(screen.getByLabelText("sort tickets by"), {
target: { value: "" },
});
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined(),
);
});
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

@ -27,7 +27,7 @@ import type {
TicketStatus,
TicketSummary,
} from "@/domain";
import type { TicketListQuery } from "@/ports";
import type { TicketListQuery, TicketListSort } from "@/ports";
import { useGateways } from "@/app/di";
/**
@ -70,6 +70,10 @@ export interface TicketSearchViewModel {
toggleStatus: (status: TicketStatus) => void;
togglePriority: (priority: TicketPriority) => void;
clearFacets: () => void;
/** Current sort (#21); `undefined` ⇒ the backend's historical order. */
sort: TicketListSort | undefined;
/** Sets the sort (or clears it); resets pagination to the first page. */
setSort: (sort: TicketListSort | undefined) => void;
/** True when the backend reported a further page (opaque cursor present). */
hasMore: boolean;
/** Loads and appends the next page (no-op when exhausted or busy). */
@ -106,6 +110,9 @@ export function useTicketSearch(
});
const [text, setText] = useState(initialQuery?.text ?? "");
const [debouncedText, setDebouncedText] = useState(text);
const [sort, setSort] = useState<TicketListSort | undefined>(
initialQuery?.sort,
);
const [items, setItems] = useState<TicketSummary[]>([]);
const [cursor, setCursor] = useState<string | undefined>(undefined);
@ -130,8 +137,8 @@ export function useTicketSearch(
// A key that changes whenever the effective query (minus cursor) changes, so
// the first-page effect refetches exactly when a criterion moves.
const queryKey = useMemo(
() => JSON.stringify({ facets, text: debouncedText.trim(), limit }),
[facets, debouncedText, limit],
() => JSON.stringify({ facets, text: debouncedText.trim(), sort, limit }),
[facets, debouncedText, sort, limit],
);
// Guards against races: only the latest first-page/loadMore fetch may commit.
@ -151,12 +158,13 @@ export function useTicketSearch(
? { assignedAgentId: facets.assignedAgentId }
: {}),
...(trimmed ? { text: trimmed } : {}),
...(sort ? { sort } : {}),
...(limit ? { limit } : {}),
// G3-bis: opaque token, relayed verbatim from the previous page only.
...(nextCursor ? { cursor: nextCursor } : {}),
};
},
[facets, debouncedText, limit],
[facets, debouncedText, sort, limit],
);
// First page: (re)fetch whenever the query key changes; replaces the list.
@ -265,6 +273,8 @@ export function useTicketSearch(
toggleStatus,
togglePriority,
clearFacets,
sort,
setSort,
hasMore: cursor !== undefined,
loadMore,
resolve,

View File

@ -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;