Files
IdeA/frontend/src/adapters/ticket.ts
Blomios feeca3462c feat(tickets): filtres multi-critères par cases à cocher (backend + frontend)
Passe les filtres de la liste des tickets en sélection multiple (#12).

Backend Rust :
- IssueListFilter : statuses/priorities en Vec, filter_matches en OR intra-champ
  et AND inter-champs
- TicketListRequestDto en tableaux + from_request (parse/déduplication)
- MCP idea_ticket_list aligné sur le nouveau contrat

Frontend :
- TicketListQuery.statuses/priorities
- UI de cases à cocher, toggle/clear des filtres

Tests verts : issue_store (7), app-tauri --lib (59), mcp_server (24),
issue_usecases (6), sprint_usecases (6), frontend vitest (505), npm run build (exit 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 06:41:01 +02:00

239 lines
6.7 KiB
TypeScript

/**
* Tauri adapter for {@link TicketGateway}.
*
* The single frontend owner of the `ticket_*` command names and their request
* envelopes (every command takes one camelCase `request` object). Features
* consume the gateway port through DI and never call `invoke()` directly.
*
* Optimistic concurrency: the backend rejects a stale write with an `INVALID`
* {@link GatewayError} whose message contains `"version conflict"`; use
* {@link isTicketVersionConflict} to branch on it in the UI.
*/
import { Channel, invoke } from "@tauri-apps/api/core";
import type {
GatewayError,
ReplyChunk,
Sprint,
Ticket,
TicketCarnet,
TicketChat,
TicketLinkKind,
TicketList,
} from "@/domain";
import type {
CreateTicketInput,
TicketGateway,
TicketListQuery,
UpdateTicketInput,
} from "@/ports";
/**
* Whether a gateway error is an optimistic-concurrency conflict (the ticket was
* mutated since it was read). The backend returns a generic `INVALID` code, so
* we key off the stable message fragment emitted by the domain store error.
*/
export function isTicketVersionConflict(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const message = (error as GatewayError).message;
return typeof message === "string" && message.includes("version conflict");
}
export class TauriTicketGateway implements TicketGateway {
async create(projectId: string, input: CreateTicketInput): Promise<Ticket> {
return invoke<Ticket>("ticket_create", {
request: { projectId, ...input },
});
}
async read(
projectId: string,
ref: string,
includeCarnet = false,
): Promise<Ticket> {
return invoke<Ticket>("ticket_read", {
request: { projectId, ref, includeCarnet },
});
}
async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
const { statuses, priorities, ...rest } = query ?? {};
// Multi-select facets (ticket #12): the backend expects `statuses`/
// `priorities` arrays (empty ⇒ no constraint on that facet).
return invoke<TicketList>("ticket_list", {
request: {
projectId,
statuses: statuses ?? [],
priorities: priorities ?? [],
...rest,
},
});
}
async update(
projectId: string,
ref: string,
input: UpdateTicketInput,
): Promise<Ticket> {
return invoke<Ticket>("ticket_update", {
request: { projectId, ref, ...input },
});
}
async delete(projectId: string, ref: string): Promise<void> {
// `ticket_delete` takes the shared `{ request }` envelope like every other
// ticket command; a missing ref surfaces as a `NOT_FOUND` GatewayError from
// the backend, propagated as-is.
await invoke<void>("ticket_delete", {
request: { projectId, ref },
});
}
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
return invoke<TicketCarnet>("ticket_read_carnet", {
request: { projectId, ref },
});
}
async updateCarnet(
projectId: string,
ref: string,
carnet: string,
expectedVersion: number,
): Promise<Ticket> {
return invoke<Ticket>("ticket_update_carnet", {
request: { projectId, ref, carnet, expectedVersion },
});
}
async link(
projectId: string,
ref: string,
targetRef: string,
kind: TicketLinkKind,
expectedVersion: number,
): Promise<Ticket> {
return invoke<Ticket>("ticket_link", {
request: { projectId, ref, targetRef, kind, expectedVersion },
});
}
async unlink(
projectId: string,
ref: string,
targetRef: string,
expectedVersion: number,
kind?: TicketLinkKind,
): Promise<Ticket> {
return invoke<Ticket>("ticket_unlink", {
request: { projectId, ref, targetRef, expectedVersion, kind },
});
}
async assign(
projectId: string,
ref: string,
agentId: string,
assigned: boolean,
expectedVersion: number,
): Promise<Ticket> {
return invoke<Ticket>("ticket_assign", {
request: { projectId, ref, agentId, assigned, expectedVersion },
});
}
async listSprints(projectId: string): Promise<Sprint[]> {
// `sprint_list` returns a `SprintListDto { items }`; unwrap to the array.
const list = await invoke<{ items: Sprint[] }>("sprint_list", {
request: { projectId },
});
return list.items;
}
async setTicketSprint(
projectId: string,
ref: string,
sprintId: string | null,
expectedVersion: number,
): Promise<Ticket> {
// Two backend commands: assign to a sprint, or unassign (clear membership).
if (sprintId === null) {
return invoke<Ticket>("ticket_unassign_sprint", {
request: { projectId, ref, expectedVersion },
});
}
return invoke<Ticket>("ticket_assign_sprint", {
request: { projectId, ref, sprintId, expectedVersion },
});
}
async createSprint(projectId: string, name: string): Promise<Sprint> {
return invoke<Sprint>("sprint_create", {
request: { projectId, name },
});
}
async renameSprint(
projectId: string,
sprintId: string,
name: string,
expectedVersion: number,
): Promise<Sprint> {
return invoke<Sprint>("sprint_rename", {
request: { projectId, sprintId, name, expectedVersion },
});
}
async reorderSprints(
projectId: string,
orderedIds: string[],
): Promise<Sprint[]> {
// `sprint_reorder` returns a `SprintListDto { items }`; unwrap to the array.
const list = await invoke<{ items: Sprint[] }>("sprint_reorder", {
request: { projectId, orderedIds },
});
return list.items;
}
async deleteSprint(projectId: string, sprintId: string): Promise<void> {
await invoke<void>("sprint_delete", {
request: { projectId, sprintId },
});
}
async openTicketChat(
projectId: string,
issueRef: string,
profileId: string,
): Promise<TicketChat> {
return invoke<TicketChat>("open_ticket_chat", {
request: { projectId, issueRef, profileId },
});
}
async closeTicketChat(projectId: string, issueRef: string): Promise<void> {
await invoke<void>("close_ticket_chat", {
request: { projectId, issueRef },
});
}
async sendTicketChat(
sessionId: string,
message: string,
onChunk: (chunk: ReplyChunk) => void,
): Promise<void> {
// Per-turn reply channel, tagged on `kind` (`textDelta`|`toolActivity`|
// `final`). Reuses the shared `agent_send` chat transport (the same command
// and ChatBridge the structured chat cell uses). Wire the handler before the
// invoke so no early chunk is missed.
const channel = new Channel<ReplyChunk>();
channel.onmessage = (chunk) => onChunk(chunk);
await invoke<void>("agent_send", {
sessionId,
prompt: message,
onReply: channel,
});
}
}