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 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,
|
||||
|
||||
@ -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" }
|
||||
},
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -218,6 +218,8 @@ function TicketPickerBody({
|
||||
onToggleStatus={vm.toggleStatus}
|
||||
onTogglePriority={vm.togglePriority}
|
||||
onClearFacets={vm.clearFacets}
|
||||
sort={vm.sort}
|
||||
onSortChange={vm.setSort}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user