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:
@ -202,10 +202,37 @@ pub struct TicketListRequestDto {
|
|||||||
pub assigned_agent_id: Option<String>,
|
pub assigned_agent_id: Option<String>,
|
||||||
pub sprint_id: Option<String>,
|
pub sprint_id: Option<String>,
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
|
pub sort: Option<TicketListSortDto>,
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
pub cursor: Option<String>,
|
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.
|
/// Update request.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -410,7 +437,7 @@ impl TicketToolProvider for AppTicketToolProvider {
|
|||||||
}
|
}
|
||||||
"idea_ticket_list" => {
|
"idea_ticket_list" => {
|
||||||
let req = mcp_list_request(project, arguments)?;
|
let req = mcp_list_request(project, arguments)?;
|
||||||
let rows = self
|
let mut rows = self
|
||||||
.list
|
.list
|
||||||
.execute(ListIssuesInput {
|
.execute(ListIssuesInput {
|
||||||
project: project.clone(),
|
project: project.clone(),
|
||||||
@ -419,6 +446,7 @@ impl TicketToolProvider for AppTicketToolProvider {
|
|||||||
.await
|
.await
|
||||||
.map_err(ticket_error)?
|
.map_err(ticket_error)?
|
||||||
.issues;
|
.issues;
|
||||||
|
sort_ticket_rows(&mut rows, req.sort);
|
||||||
let sprints = self
|
let sprints = self
|
||||||
.list_sprints
|
.list_sprints
|
||||||
.execute(ListSprintsInput {
|
.execute(ListSprintsInput {
|
||||||
@ -684,7 +712,7 @@ pub async fn ticket_list(
|
|||||||
) -> Result<TicketListDto, ErrorDto> {
|
) -> Result<TicketListDto, ErrorDto> {
|
||||||
let project = resolve_project(&state, &request.project_id).await?;
|
let project = resolve_project(&state, &request.project_id).await?;
|
||||||
let page = TicketListPageInput::from_request(request)?;
|
let page = TicketListPageInput::from_request(request)?;
|
||||||
let rows = state
|
let mut rows = state
|
||||||
.list_issues
|
.list_issues
|
||||||
.execute(ListIssuesInput {
|
.execute(ListIssuesInput {
|
||||||
project,
|
project,
|
||||||
@ -693,6 +721,7 @@ pub async fn ticket_list(
|
|||||||
.await
|
.await
|
||||||
.map_err(ErrorDto::from)?
|
.map_err(ErrorDto::from)?
|
||||||
.issues;
|
.issues;
|
||||||
|
sort_ticket_rows(&mut rows, page.sort);
|
||||||
Ok(paginate(rows, page.limit, page.cursor))
|
Ok(paginate(rows, page.limit, page.cursor))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1114,6 +1143,7 @@ impl From<IssueCarnet> for TicketCarnetDto {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct TicketListPageInput {
|
struct TicketListPageInput {
|
||||||
filter: IssueListFilter,
|
filter: IssueListFilter,
|
||||||
|
sort: Option<TicketListSortDto>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
cursor: Option<String>,
|
cursor: Option<String>,
|
||||||
}
|
}
|
||||||
@ -1136,12 +1166,59 @@ impl TicketListPageInput {
|
|||||||
.transpose()?,
|
.transpose()?,
|
||||||
text: request.text,
|
text: request.text,
|
||||||
},
|
},
|
||||||
|
sort: request.sort,
|
||||||
limit: request.limit.unwrap_or(100).clamp(1, 500),
|
limit: request.limit.unwrap_or(100).clamp(1, 500),
|
||||||
cursor: request.cursor,
|
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> {
|
async fn resolve_project(state: &AppState, project_id: &str) -> Result<Project, ErrorDto> {
|
||||||
let id = ProjectId::from_uuid(
|
let id = ProjectId::from_uuid(
|
||||||
Uuid::parse_str(project_id)
|
Uuid::parse_str(project_id)
|
||||||
@ -1556,6 +1633,7 @@ mod tests {
|
|||||||
assigned_agent_id: None,
|
assigned_agent_id: None,
|
||||||
sprint_id: None,
|
sprint_id: None,
|
||||||
text: None,
|
text: None,
|
||||||
|
sort: None,
|
||||||
limit: None,
|
limit: None,
|
||||||
cursor: None,
|
cursor: None,
|
||||||
})
|
})
|
||||||
@ -1580,6 +1658,7 @@ mod tests {
|
|||||||
assigned_agent_id: None,
|
assigned_agent_id: None,
|
||||||
sprint_id: None,
|
sprint_id: None,
|
||||||
text: None,
|
text: None,
|
||||||
|
sort: None,
|
||||||
limit: None,
|
limit: None,
|
||||||
cursor: None,
|
cursor: None,
|
||||||
})
|
})
|
||||||
@ -1598,6 +1677,7 @@ mod tests {
|
|||||||
assigned_agent_id: None,
|
assigned_agent_id: None,
|
||||||
sprint_id: None,
|
sprint_id: None,
|
||||||
text: None,
|
text: None,
|
||||||
|
sort: None,
|
||||||
limit: Some(1),
|
limit: Some(1),
|
||||||
cursor: Some("1".into()),
|
cursor: Some("1".into()),
|
||||||
})
|
})
|
||||||
@ -1619,6 +1699,42 @@ mod tests {
|
|||||||
assert_eq!(out.next_cursor, Some("2".to_owned()));
|
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(
|
fn issue_row(
|
||||||
number: u64,
|
number: u64,
|
||||||
status: IssueStatus,
|
status: IssueStatus,
|
||||||
|
|||||||
@ -74,6 +74,15 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
let ticket_ref = json!({ "type": "string", "pattern": "^#[1-9][0-9]*$" });
|
let ticket_ref = json!({ "type": "string", "pattern": "^#[1-9][0-9]*$" });
|
||||||
let status = json!({ "type": "string", "enum": ["open", "inProgress", "QA", "closed"] });
|
let status = json!({ "type": "string", "enum": ["open", "inProgress", "QA", "closed"] });
|
||||||
let priority = json!({ "type": "string", "enum": ["low", "medium", "high", "critical"] });
|
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!({
|
let link_kind = json!({
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["relatesTo", "blocks", "blockedBy", "duplicates", "dependsOn"]
|
"enum": ["relatesTo", "blocks", "blockedBy", "duplicates", "dependsOn"]
|
||||||
@ -137,6 +146,7 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
},
|
},
|
||||||
"assignedAgentId": { "type": "string", "format": "uuid" },
|
"assignedAgentId": { "type": "string", "format": "uuid" },
|
||||||
"text": { "type": "string" },
|
"text": { "type": "string" },
|
||||||
|
"sort": sort.clone(),
|
||||||
"limit": { "type": "integer", "minimum": 1 },
|
"limit": { "type": "integer", "minimum": 1 },
|
||||||
"cursor": { "type": "string" }
|
"cursor": { "type": "string" }
|
||||||
},
|
},
|
||||||
|
|||||||
@ -46,6 +46,8 @@ import type {
|
|||||||
TicketLink,
|
TicketLink,
|
||||||
TicketLinkKind,
|
TicketLinkKind,
|
||||||
TicketList,
|
TicketList,
|
||||||
|
TicketPriority,
|
||||||
|
TicketStatus,
|
||||||
TurnPage,
|
TurnPage,
|
||||||
TurnView,
|
TurnView,
|
||||||
Unsubscribe,
|
Unsubscribe,
|
||||||
@ -87,6 +89,44 @@ import type {
|
|||||||
import { normalizeProjectWorkState } from "../workStateNormalization";
|
import { normalizeProjectWorkState } from "../workStateNormalization";
|
||||||
import { applyOperation, singleLeafTree } from "@/features/layout/layout";
|
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 {
|
export class MockSystemGateway implements SystemGateway {
|
||||||
private listeners = new Set<(e: DomainEvent) => void>();
|
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).
|
// empty/absent set means "no constraint" (all values pass).
|
||||||
const statuses = q.statuses ?? [];
|
const statuses = q.statuses ?? [];
|
||||||
const priorities = q.priorities ?? [];
|
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) => statuses.length === 0 || statuses.includes(t.status))
|
||||||
.filter((t) => priorities.length === 0 || priorities.includes(t.priority))
|
.filter((t) => priorities.length === 0 || priorities.includes(t.priority))
|
||||||
.filter(
|
.filter(
|
||||||
@ -1967,8 +2007,10 @@ export class MockTicketGateway implements TicketGateway {
|
|||||||
!text ||
|
!text ||
|
||||||
t.title.toLowerCase().includes(text) ||
|
t.title.toLowerCase().includes(text) ||
|
||||||
t.description.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 start = Number.parseInt(q.cursor ?? "0", 10) || 0;
|
||||||
const limit = Math.min(Math.max(q.limit ?? 100, 1), 500);
|
const limit = Math.min(Math.max(q.limit ?? 100, 1), 500);
|
||||||
const end = Math.min(rows.length, start + limit);
|
const end = Math.min(rows.length, start + limit);
|
||||||
|
|||||||
@ -14,7 +14,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TicketPriority, TicketStatus } from "@/domain";
|
import type { TicketPriority, TicketStatus } from "@/domain";
|
||||||
import { Button, Input } from "@/shared";
|
import type { TicketListSort, TicketListSortField } from "@/ports";
|
||||||
|
import { Button, Input, cn } from "@/shared";
|
||||||
import {
|
import {
|
||||||
PriorityBadge,
|
PriorityBadge,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@ -24,6 +25,23 @@ import {
|
|||||||
statusLabel,
|
statusLabel,
|
||||||
} from "./ticketMeta";
|
} 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 {
|
export interface TicketFacetsBarProps {
|
||||||
/** Current free-text search value (controlled). */
|
/** Current free-text search value (controlled). */
|
||||||
text: string;
|
text: string;
|
||||||
@ -42,6 +60,13 @@ export interface TicketFacetsBarProps {
|
|||||||
onClearFacets?: () => void;
|
onClearFacets?: () => void;
|
||||||
/** Placeholder for the search input. */
|
/** Placeholder for the search input. */
|
||||||
searchPlaceholder?: string;
|
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({
|
export function TicketFacetsBar({
|
||||||
@ -53,6 +78,8 @@ export function TicketFacetsBar({
|
|||||||
onTogglePriority,
|
onTogglePriority,
|
||||||
onClearFacets,
|
onClearFacets,
|
||||||
searchPlaceholder = "Search title/description…",
|
searchPlaceholder = "Search title/description…",
|
||||||
|
sort,
|
||||||
|
onSortChange,
|
||||||
}: TicketFacetsBarProps) {
|
}: TicketFacetsBarProps) {
|
||||||
const hasFacets = statuses.length > 0 || priorities.length > 0;
|
const hasFacets = statuses.length > 0 || priorities.length > 0;
|
||||||
return (
|
return (
|
||||||
@ -109,6 +136,51 @@ export function TicketFacetsBar({
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</fieldset>
|
</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 && (
|
{onClearFacets && hasFacets && (
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -137,6 +137,49 @@ describe("TicketPicker (#18)", () => {
|
|||||||
expect(screen.getByText("Bravo task")).toBeTruthy();
|
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 () => {
|
it("debounced text search narrows the list", async () => {
|
||||||
await seedTrio(ticket);
|
await seedTrio(ticket);
|
||||||
renderPicker(ticket);
|
renderPicker(ticket);
|
||||||
|
|||||||
@ -218,6 +218,8 @@ function TicketPickerBody({
|
|||||||
onToggleStatus={vm.toggleStatus}
|
onToggleStatus={vm.toggleStatus}
|
||||||
onTogglePriority={vm.togglePriority}
|
onTogglePriority={vm.togglePriority}
|
||||||
onClearFacets={vm.clearFacets}
|
onClearFacets={vm.clearFacets}
|
||||||
|
sort={vm.sort}
|
||||||
|
onSortChange={vm.setSort}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -163,6 +163,11 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
onToggleStatus={vm.toggleStatus}
|
onToggleStatus={vm.toggleStatus}
|
||||||
onTogglePriority={vm.togglePriority}
|
onTogglePriority={vm.togglePriority}
|
||||||
onClearFacets={vm.clearFacets}
|
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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<select
|
<select
|
||||||
|
|||||||
@ -267,6 +267,48 @@ describe("TicketsView", () => {
|
|||||||
expect(screen.getByText("Alpha")).toBeTruthy();
|
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 () => {
|
it("opens the detail and edits status with a version bump", async () => {
|
||||||
const t = await ticket.create(PROJECT_ID, { title: "Editable" });
|
const t = await ticket.create(PROJECT_ID, { title: "Editable" });
|
||||||
renderView(ticket, system, agent);
|
renderView(ticket, system, agent);
|
||||||
|
|||||||
@ -27,7 +27,7 @@ import type {
|
|||||||
TicketStatus,
|
TicketStatus,
|
||||||
TicketSummary,
|
TicketSummary,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import type { TicketListQuery } from "@/ports";
|
import type { TicketListQuery, TicketListSort } from "@/ports";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -70,6 +70,10 @@ export interface TicketSearchViewModel {
|
|||||||
toggleStatus: (status: TicketStatus) => void;
|
toggleStatus: (status: TicketStatus) => void;
|
||||||
togglePriority: (priority: TicketPriority) => void;
|
togglePriority: (priority: TicketPriority) => void;
|
||||||
clearFacets: () => 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). */
|
/** True when the backend reported a further page (opaque cursor present). */
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
/** Loads and appends the next page (no-op when exhausted or busy). */
|
/** 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 [text, setText] = useState(initialQuery?.text ?? "");
|
||||||
const [debouncedText, setDebouncedText] = useState(text);
|
const [debouncedText, setDebouncedText] = useState(text);
|
||||||
|
const [sort, setSort] = useState<TicketListSort | undefined>(
|
||||||
|
initialQuery?.sort,
|
||||||
|
);
|
||||||
|
|
||||||
const [items, setItems] = useState<TicketSummary[]>([]);
|
const [items, setItems] = useState<TicketSummary[]>([]);
|
||||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
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
|
// A key that changes whenever the effective query (minus cursor) changes, so
|
||||||
// the first-page effect refetches exactly when a criterion moves.
|
// the first-page effect refetches exactly when a criterion moves.
|
||||||
const queryKey = useMemo(
|
const queryKey = useMemo(
|
||||||
() => JSON.stringify({ facets, text: debouncedText.trim(), limit }),
|
() => JSON.stringify({ facets, text: debouncedText.trim(), sort, limit }),
|
||||||
[facets, debouncedText, limit],
|
[facets, debouncedText, sort, limit],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Guards against races: only the latest first-page/loadMore fetch may commit.
|
// Guards against races: only the latest first-page/loadMore fetch may commit.
|
||||||
@ -151,12 +158,13 @@ export function useTicketSearch(
|
|||||||
? { assignedAgentId: facets.assignedAgentId }
|
? { assignedAgentId: facets.assignedAgentId }
|
||||||
: {}),
|
: {}),
|
||||||
...(trimmed ? { text: trimmed } : {}),
|
...(trimmed ? { text: trimmed } : {}),
|
||||||
|
...(sort ? { sort } : {}),
|
||||||
...(limit ? { limit } : {}),
|
...(limit ? { limit } : {}),
|
||||||
// G3-bis: opaque token, relayed verbatim from the previous page only.
|
// G3-bis: opaque token, relayed verbatim from the previous page only.
|
||||||
...(nextCursor ? { cursor: nextCursor } : {}),
|
...(nextCursor ? { cursor: nextCursor } : {}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[facets, debouncedText, limit],
|
[facets, debouncedText, sort, limit],
|
||||||
);
|
);
|
||||||
|
|
||||||
// First page: (re)fetch whenever the query key changes; replaces the list.
|
// First page: (re)fetch whenever the query key changes; replaces the list.
|
||||||
@ -265,6 +273,8 @@ export function useTicketSearch(
|
|||||||
toggleStatus,
|
toggleStatus,
|
||||||
togglePriority,
|
togglePriority,
|
||||||
clearFacets,
|
clearFacets,
|
||||||
|
sort,
|
||||||
|
setSort,
|
||||||
hasMore: cursor !== undefined,
|
hasMore: cursor !== undefined,
|
||||||
loadMore,
|
loadMore,
|
||||||
resolve,
|
resolve,
|
||||||
|
|||||||
@ -713,6 +713,7 @@ export interface ConversationGateway {
|
|||||||
* Status and priority are **multi-select**: `statuses`/`priorities` are sets of
|
* Status and priority are **multi-select**: `statuses`/`priorities` are sets of
|
||||||
* accepted values with OR semantics *within* a facet and AND *across* facets. An
|
* 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).
|
* 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 {
|
export interface TicketListQuery {
|
||||||
statuses?: TicketStatus[];
|
statuses?: TicketStatus[];
|
||||||
@ -720,10 +721,20 @@ export interface TicketListQuery {
|
|||||||
assignedAgentId?: string;
|
assignedAgentId?: string;
|
||||||
/** Free-text match over title/description. */
|
/** Free-text match over title/description. */
|
||||||
text?: string;
|
text?: string;
|
||||||
|
sort?: TicketListSort;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
cursor?: string;
|
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}. */
|
/** Input for {@link TicketGateway.create}. */
|
||||||
export interface CreateTicketInput {
|
export interface CreateTicketInput {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user