Ticket #11 — surface UI de gestion des sprints (frontend pur). - Nouveau composant SprintManager : création (nom optionnel), édition et gestion du cycle de vie d'un sprint, contrôles accessibles (pas de DnD). - useTickets étendu aux opérations de gestion de sprint ; ports, adaptateurs (ticket.ts, mock/index.ts) et TicketsPanel câblés en conséquence. - Tests Vitest associés (tickets.test.tsx). Typecheck / vitest (497+ tests) / build verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
186 lines
5.0 KiB
TypeScript
186 lines
5.0 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 { invoke } from "@tauri-apps/api/core";
|
|
|
|
import type {
|
|
GatewayError,
|
|
Sprint,
|
|
Ticket,
|
|
TicketCarnet,
|
|
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> {
|
|
return invoke<TicketList>("ticket_list", {
|
|
request: { projectId, ...(query ?? {}) },
|
|
});
|
|
}
|
|
|
|
async update(
|
|
projectId: string,
|
|
ref: string,
|
|
input: UpdateTicketInput,
|
|
): Promise<Ticket> {
|
|
return invoke<Ticket>("ticket_update", {
|
|
request: { projectId, ref, ...input },
|
|
});
|
|
}
|
|
|
|
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 },
|
|
});
|
|
}
|
|
}
|