feat(tickets): contrôle « Trier par… » dans la barre de facettes (#21)

Ajoute le sélecteur de tri dans TicketFacetsBar et propage le critère
via useTicketSearch jusqu'à TicketsPanel et TicketPicker, sur le contrat
de tri backend (e523f44). Couvre le tri par les tests tickets et
TicketPicker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 11:54:28 +02:00
parent e523f44425
commit bd73fd79bd
6 changed files with 179 additions and 5 deletions

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,