feat(sprints): interface de création et gestion des sprints (frontend)
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>
This commit is contained in:
@ -1815,6 +1815,7 @@ export class MockTicketGateway implements TicketGateway {
|
||||
private carnets = new Map<string, Map<string, string>>();
|
||||
private counters = new Map<string, number>();
|
||||
private sprints = new Map<string, Map<string, Sprint>>();
|
||||
private sprintCounters = new Map<string, number>();
|
||||
|
||||
constructor(private readonly system?: MockSystemGateway) {}
|
||||
|
||||
@ -2141,6 +2142,118 @@ export class MockTicketGateway implements TicketGateway {
|
||||
});
|
||||
return structuredClone(ticket);
|
||||
}
|
||||
|
||||
async createSprint(projectId: string, name: string): Promise<Sprint> {
|
||||
const sprints = this.projectSprints(projectId);
|
||||
const seq = (this.sprintCounters.get(projectId) ?? 0) + 1;
|
||||
this.sprintCounters.set(projectId, seq);
|
||||
const order =
|
||||
sprints.size === 0
|
||||
? 1
|
||||
: Math.max(...[...sprints.values()].map((s) => s.order)) + 1;
|
||||
const sprint: Sprint = {
|
||||
id: `mock-sprint-${projectId}-${seq}`,
|
||||
order,
|
||||
name,
|
||||
status: "planned",
|
||||
ticketCount: 0,
|
||||
version: 1,
|
||||
};
|
||||
sprints.set(sprint.id, sprint);
|
||||
this.system?.emit({
|
||||
type: "sprintCreated",
|
||||
sprintId: sprint.id,
|
||||
order: sprint.order,
|
||||
});
|
||||
return structuredClone(sprint);
|
||||
}
|
||||
|
||||
async renameSprint(
|
||||
projectId: string,
|
||||
sprintId: string,
|
||||
name: string,
|
||||
expectedVersion: number,
|
||||
): Promise<Sprint> {
|
||||
const sprint = this.requireSprint(projectId, sprintId);
|
||||
this.guardSprint(sprint, expectedVersion);
|
||||
sprint.name = name;
|
||||
sprint.version += 1;
|
||||
this.system?.emit({
|
||||
type: "sprintRenamed",
|
||||
sprintId,
|
||||
name,
|
||||
version: sprint.version,
|
||||
});
|
||||
return structuredClone(sprint);
|
||||
}
|
||||
|
||||
async reorderSprints(
|
||||
projectId: string,
|
||||
orderedIds: string[],
|
||||
): Promise<Sprint[]> {
|
||||
const sprints = this.projectSprints(projectId);
|
||||
for (const id of orderedIds) this.requireSprint(projectId, id);
|
||||
// Assign 1-based order following the provided id sequence; ids omitted from
|
||||
// `orderedIds` keep their relative order after the listed ones.
|
||||
const rest = [...sprints.values()]
|
||||
.filter((s) => !orderedIds.includes(s.id))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s) => s.id);
|
||||
[...orderedIds, ...rest].forEach((id, index) => {
|
||||
const sprint = sprints.get(id);
|
||||
if (sprint) {
|
||||
sprint.order = index + 1;
|
||||
this.system?.emit({
|
||||
type: "sprintReordered",
|
||||
sprintId: id,
|
||||
order: sprint.order,
|
||||
version: sprint.version,
|
||||
});
|
||||
}
|
||||
});
|
||||
return this.listSprints(projectId);
|
||||
}
|
||||
|
||||
async deleteSprint(projectId: string, sprintId: string): Promise<void> {
|
||||
this.requireSprint(projectId, sprintId);
|
||||
// Delete unassigns the sprint's tickets (it does NOT delete them): they fall
|
||||
// back to the "no sprint" bucket.
|
||||
for (const ticket of this.projectTickets(projectId).values()) {
|
||||
if (ticket.sprintId === sprintId) {
|
||||
ticket.sprintId = null;
|
||||
this.bump(ticket);
|
||||
this.system?.emit({
|
||||
type: "issueSprintChanged",
|
||||
issueRef: ticket.ref,
|
||||
from: sprintId,
|
||||
to: null,
|
||||
version: ticket.version,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.projectSprints(projectId).delete(sprintId);
|
||||
this.system?.emit({ type: "sprintDeleted", sprintId });
|
||||
}
|
||||
|
||||
private requireSprint(projectId: string, sprintId: string): Sprint {
|
||||
const sprint = this.projectSprints(projectId).get(sprintId);
|
||||
if (!sprint) {
|
||||
throw {
|
||||
code: "NOT_FOUND",
|
||||
message: `sprint ${sprintId} not found`,
|
||||
} as GatewayError;
|
||||
}
|
||||
return sprint;
|
||||
}
|
||||
|
||||
private guardSprint(sprint: Sprint, expectedVersion: number): void {
|
||||
if (sprint.version !== expectedVersion) {
|
||||
throw {
|
||||
code: "INVALID",
|
||||
message: `sprint version conflict: expected ${expectedVersion}, actual ${sprint.version}`,
|
||||
} as GatewayError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mostRestrictive(
|
||||
|
||||
@ -148,4 +148,38 @@ export class TauriTicketGateway implements TicketGateway {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user