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 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,

View File

@ -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" }
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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