feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)

Lot en un seul commit: les deux features partagent les mêmes contrats
(domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes
surfaces UI — la séparation par fichiers était impossible sans chirurgie
de hunks risquant des états intermédiaires non compilables (convention
lot des batches #310-#313 et #315).

#325 — icône configurable par projet, watermark CLI custom:
- domaine: ProjectIconSet (devicon, simple-icons, lucide, idea),
  ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon
  optionnel sur ProjectSettings; sérialisation camelCase et try_from
  strict
- application: UpdateProjectSettings.execute accepte un patch
  theme_color OU icon (absent = inchangé, null = défaut produit), tests
  in-dependants avec store mémoire
- backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto
  avec icon optionnel (désérialisation additive), commandes
  update_project_settings alignées Tauri/HTTP, tests DTO et HTTP
  (défaut null, set, clear, nom invalide rejeté)
- frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en
  grille multi-lignes dans Paramètres projet, affichage de l'icône dans
  ProjectTabs et watermark grand format à faible opacité en fond de CLI
  custom; ports ProjectSettings.icon, Partial<ProjectSettings>,
  adapters tauri/mock/http et tests alignés

#326 — localisation FR/EN et traduction des textes IdeA:
- domaine: IdeaLanguage (fr | en), champ language optionnel sur
  IdeaUserSettings (absence = défaut produit, série additive validée)
- backend/web-server: UserSettingsDto.language, update_user_settings
  accepte language, tests HTTP alignés; fixtures structured_launch_d3
  mises à jour
- frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression
  des sources visibles), GeneralSettings avec sélecteur de langue dans
  les paramètres, setupI18n dans vitest; migration de l'ensemble des
  textes hardcodés vers le catalogue sur toutes les surfaces (agents,
  layout, projets, tickets, sprints, web, plugins, permissions, git,
  templates, skills, mémoire, model servers, embedder, first-run,
  devices, scheduled tasks, workstate, dialogs), FR conservé par défaut;
  recherche d'icônes #325 en anglais conformément au ticket

QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
This commit is contained in:
2026-09-11 21:53:56 +02:00
parent cd744b5f18
commit 1a1429f10e
157 changed files with 4531 additions and 3129 deletions

View File

@ -21,6 +21,7 @@
import { useState } from "react";
import { Button, Input } from "@/shared";
import { useI18n } from "@/i18n";
import { PriorityBadge, StatusBadge, TicketRef } from "./ticketMeta";
import { TicketFacetsBar } from "./TicketFacetsBar";
import { TicketPicker } from "./TicketPicker";
@ -34,6 +35,7 @@ export interface SprintManagerProps {
}
export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
const { tr } = useI18n();
// Own ticket query, independent of the main panel filter (#37). No default
// `statuses` → closed/done tickets remain visible; the facets bar below drives
// per-status/priority filtering + sort of the whole sprint view.
@ -68,13 +70,13 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
className="fixed inset-0 z-50 flex flex-col bg-canvas"
role="dialog"
aria-modal="true"
aria-label="manage sprints"
aria-label={tr("gérer les sprints", "manage sprints")}
>
{/* ── Header ── */}
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3">
<span className="text-sm font-medium text-content">Manage sprints</span>
<span className="text-sm font-medium text-content">{tr("Gérer les sprints", "Manage sprints")}</span>
<Button size="sm" variant="ghost" onClick={onClose}>
Close
{tr("Fermer", "Close")}
</Button>
</header>
@ -94,14 +96,14 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
className="mb-5 flex items-center gap-2 rounded-md border border-border bg-raised/50 p-2"
>
<Input
aria-label="new sprint name"
placeholder="Sprint name (optional)"
aria-label={tr("nom du nouveau sprint", "new sprint name")}
placeholder={tr("Nom du sprint (facultatif)", "Sprint name (optional)")}
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="max-w-xs"
/>
<Button type="submit" size="sm" disabled={vm.busy}>
Create sprint
{tr("Créer le sprint", "Create sprint")}
</Button>
</form>
@ -131,7 +133,7 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
)}
{sprints.length === 0 ? (
<p className="text-sm text-muted">No sprints yet.</p>
<p className="text-sm text-muted">{tr("Aucun sprint.", "No sprints yet.")}</p>
) : (
<ul className="flex flex-col gap-4">
{sprints.map((sprint, index) => {
@ -140,7 +142,7 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
return (
<li
key={sprint.id}
aria-label={`sprint row ${sprint.name}`}
aria-label={tr(`ligne du sprint ${sprint.name}`, `sprint row ${sprint.name}`)}
className="rounded-md border border-border p-3"
>
{/* ── Row header: order, rename, reorder, delete ── */}
@ -149,7 +151,7 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
#{sprint.order}
</span>
<Input
aria-label={`rename sprint ${sprint.name}`}
aria-label={tr(`renommer le sprint ${sprint.name}`, `rename sprint ${sprint.name}`)}
value={draft}
disabled={vm.busy}
onChange={(e) =>
@ -162,7 +164,7 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
/>
<Button
size="sm"
aria-label={`save sprint ${sprint.name}`}
aria-label={tr(`enregistrer le sprint ${sprint.name}`, `save sprint ${sprint.name}`)}
disabled={
vm.busy ||
draft.trim() === "" ||
@ -181,13 +183,13 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
}
}}
>
Save
{tr("Enregistrer", "Save")}
</Button>
<div className="ml-auto flex items-center gap-1">
<Button
size="sm"
variant="ghost"
aria-label={`move sprint ${sprint.name} up`}
aria-label={tr(`monter le sprint ${sprint.name}`, `move sprint ${sprint.name} up`)}
disabled={vm.busy || index === 0}
onClick={() => void vm.moveSprint(sprint.id, "up")}
>
@ -196,7 +198,7 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
<Button
size="sm"
variant="ghost"
aria-label={`move sprint ${sprint.name} down`}
aria-label={tr(`descendre le sprint ${sprint.name}`, `move sprint ${sprint.name} down`)}
disabled={vm.busy || index === sprints.length - 1}
onClick={() => void vm.moveSprint(sprint.id, "down")}
>
@ -205,14 +207,14 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
<Button
size="sm"
variant="ghost"
aria-label={`delete sprint ${sprint.name}`}
aria-label={tr(`supprimer le sprint ${sprint.name}`, `delete sprint ${sprint.name}`)}
disabled={vm.busy}
className="text-danger hover:text-danger"
onClick={() =>
setConfirmDelete({ id: sprint.id, name: sprint.name })
}
>
Delete
{tr("Supprimer", "Delete")}
</Button>
</div>
</div>
@ -221,7 +223,7 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
#37; kept visible even once closed/done) ── */}
<div className="mt-3 flex flex-col gap-1">
{inSprint.length === 0 ? (
<p className="text-xs text-muted">No tickets in this sprint.</p>
<p className="text-xs text-muted">{tr("Aucun ticket dans ce sprint.", "No tickets in this sprint.")}</p>
) : (
<ul className="flex flex-col gap-1">
{inSprint.map((t) => (
@ -240,11 +242,11 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
<Button
size="sm"
variant="ghost"
aria-label={`remove ${t.ref} from sprint`}
aria-label={tr(`retirer ${t.ref} du sprint`, `remove ${t.ref} from sprint`)}
disabled={vm.busy}
onClick={() => void vm.assignSprint(t.ref, null)}
>
Remove
{tr("Retirer", "Remove")}
</Button>
</li>
))}
@ -257,13 +259,13 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
<Button
size="sm"
variant="ghost"
aria-label={`add ticket to sprint ${sprint.name}`}
aria-label={tr(`ajouter un ticket au sprint ${sprint.name}`, `add ticket to sprint ${sprint.name}`)}
disabled={vm.busy}
onClick={() =>
setPickerSprint({ id: sprint.id, name: sprint.name })
}
>
Ajouter des tickets
{tr("Ajouter des tickets", "Add tickets")}
</Button>
</div>
</div>
@ -282,7 +284,7 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
loading={search.busy}
onClick={() => search.loadMore()}
>
Load more tickets
{tr("Charger plus de tickets", "Load more tickets")}
</Button>
</div>
)}
@ -293,31 +295,29 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
<div
role="dialog"
aria-modal="true"
aria-label="delete sprint confirmation"
aria-label={tr("confirmation de suppression du sprint", "delete sprint confirmation")}
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/55 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
<h4 className="text-sm font-semibold text-content">
Supprimer le sprint « {confirmDelete.name} »&nbsp;?
{tr(`Supprimer le sprint « ${confirmDelete.name} » ?`, `Delete sprint “${confirmDelete.name}”?`)}
</h4>
<p className="mt-2 text-sm text-muted">
Les tickets de ce sprint ne seront pas supprimés&nbsp;: ils seront
simplement désassignés et retourneront au backlog «&nbsp;Sans
sprint&nbsp;».
{tr("Les tickets de ce sprint ne seront pas supprimés : ils seront simplement désassignés et retourneront au backlog « Sans sprint ».", "Tickets in this sprint will not be deleted: they will simply be unassigned and returned to the No sprint backlog.")}
</p>
<div className="mt-4 flex items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
aria-label="cancel delete sprint"
aria-label={tr("annuler la suppression du sprint", "cancel delete sprint")}
disabled={vm.busy}
onClick={() => setConfirmDelete(null)}
>
Annuler
{tr("Annuler", "Cancel")}
</Button>
<Button
size="sm"
aria-label="confirm delete sprint"
aria-label={tr("confirmer la suppression du sprint", "confirm delete sprint")}
className="text-danger hover:text-danger"
disabled={vm.busy}
onClick={async () => {
@ -325,7 +325,7 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
setConfirmDelete(null);
}}
>
Supprimer
{tr("Supprimer", "Delete")}
</Button>
</div>
</div>
@ -340,10 +340,10 @@ export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
projectId={projectId}
title={
pickerSprint
? `Ajouter des tickets au sprint « ${pickerSprint.name} »`
: "Ajouter des tickets"
? tr(`Ajouter des tickets au sprint « ${pickerSprint.name} »`, `Add tickets to sprint “${pickerSprint.name}”`)
: tr("Ajouter des tickets", "Add tickets")
}
confirmLabel="Ajouter au sprint"
confirmLabel={tr("Ajouter au sprint", "Add to sprint")}
selectionMode="multi"
excludeRefs={items
.filter((t) => t.sprintId === pickerSprint?.id)

View File

@ -74,17 +74,17 @@ describe("SprintPicker (#38)", () => {
renderPicker(ticket);
// "Sans sprint" is always present.
expect(screen.getByLabelText("select no sprint")).toBeTruthy();
expect(screen.getByLabelText("Sélectionner aucun sprint")).toBeTruthy();
// Both sprints appear once loaded, ordered by `order` (Alpha before Beta).
await screen.findByLabelText("select sprint Alpha");
await screen.findByLabelText("Sélectionner le sprint Alpha");
const options = screen
.getAllByRole("option")
.map((n) => n.getAttribute("aria-label"));
expect(options).toEqual([
"select no sprint",
"select sprint Alpha",
"select sprint Beta",
"Sélectionner aucun sprint",
"Sélectionner le sprint Alpha",
"Sélectionner le sprint Beta",
]);
});
@ -92,7 +92,7 @@ describe("SprintPicker (#38)", () => {
const { alpha } = seedSprints(ticket);
const { onSelect, onClose } = renderPicker(ticket);
fireEvent.click(await screen.findByLabelText("select sprint Alpha"));
fireEvent.click(await screen.findByLabelText("Sélectionner le sprint Alpha"));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect.mock.calls[0][0]).toMatchObject({
@ -108,8 +108,8 @@ describe("SprintPicker (#38)", () => {
selectedSprintId: "s1",
});
await screen.findByLabelText("select sprint Alpha");
fireEvent.click(screen.getByLabelText("select no sprint"));
await screen.findByLabelText("Sélectionner le sprint Alpha");
fireEvent.click(screen.getByLabelText("Sélectionner aucun sprint"));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect.mock.calls[0][0]).toBeNull();
@ -120,28 +120,28 @@ describe("SprintPicker (#38)", () => {
seedSprints(ticket);
renderPicker(ticket, { selectedSprintId: "s2" });
const beta = await screen.findByLabelText("select sprint Beta");
const beta = await screen.findByLabelText("Sélectionner le sprint Beta");
expect(beta.getAttribute("aria-selected")).toBe("true");
expect(
screen.getByLabelText("select sprint Alpha").getAttribute("aria-selected"),
screen.getByLabelText("Sélectionner le sprint Alpha").getAttribute("aria-selected"),
).toBe("false");
// "Sans sprint" is not selected when a sprint id is active.
expect(
screen.getByLabelText("select no sprint").getAttribute("aria-selected"),
screen.getByLabelText("Sélectionner aucun sprint").getAttribute("aria-selected"),
).toBe("false");
});
it("shows an empty state when the project has no sprints", async () => {
renderPicker(ticket);
expect(await screen.findByText("No sprints yet.")).toBeTruthy();
expect(await screen.findByText("Aucun sprint pour le moment.")).toBeTruthy();
// "Sans sprint" is still offered so the user can clear an assignment.
expect(screen.getByLabelText("select no sprint")).toBeTruthy();
expect(screen.getByLabelText("Sélectionner aucun sprint")).toBeTruthy();
});
it("closes on Escape and on backdrop click", async () => {
seedSprints(ticket);
const { onClose } = renderPicker(ticket);
await screen.findByLabelText("select sprint Alpha");
await screen.findByLabelText("Sélectionner le sprint Alpha");
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);

View File

@ -20,6 +20,7 @@ import { useEffect, useRef, useState } from "react";
import type { GatewayError, Sprint } from "@/domain";
import { Button, Spinner, cn, zIndex } from "@/shared";
import { useI18n } from "@/i18n";
import { useGateways } from "@/app/di";
export interface SprintPickerProps {
@ -44,16 +45,17 @@ export function SprintPicker({
open,
projectId,
selectedSprintId,
title = "Sélectionner un sprint",
title,
onSelect,
onClose,
}: SprintPickerProps) {
const { tr } = useI18n();
if (!open) return null;
return (
<SprintPickerBody
projectId={projectId}
selectedSprintId={selectedSprintId ?? null}
title={title}
title={title ?? tr("Sélectionner un sprint", "Select a sprint")}
onSelect={onSelect}
onClose={onClose}
/>
@ -79,6 +81,7 @@ function SprintPickerBody({
onSelect,
onClose,
}: Required<Omit<SprintPickerProps, "open">>) {
const { tr } = useI18n();
const { ticket } = useGateways();
const dialogRef = useRef<HTMLDivElement>(null);
const [sprints, setSprints] = useState<Sprint[] | null>(null);
@ -173,10 +176,10 @@ function SprintPickerBody({
<Button
size="sm"
variant="ghost"
aria-label="close sprint picker"
aria-label={tr("Fermer le sélecteur de sprint", "Close sprint picker")}
onClick={onClose}
>
Close
{tr("Fermer", "Close")}
</Button>
</header>
@ -195,8 +198,8 @@ function SprintPickerBody({
{/* "Sans sprint" always available (clears the assignment). */}
<li className="py-1.5 first:pt-0">
<SprintOption
label="Sans sprint"
ariaLabel="select no sprint"
label={tr("Sans sprint", "No sprint")}
ariaLabel={tr("Sélectionner aucun sprint", "Select no sprint")}
selected={selectedSprintId === null}
onClick={() => pick(null)}
/>
@ -205,14 +208,14 @@ function SprintPickerBody({
{sprints === null ? (
<li className="flex items-center gap-2 py-2 text-sm text-muted">
<Spinner size={14} />
<span>Loading sprints…</span>
<span>{tr("Chargement des sprints…", "Loading sprints…")}</span>
</li>
) : (
ordered.map((s) => (
<li key={s.id} className="py-1.5 last:pb-0">
<SprintOption
label={s.name}
ariaLabel={`select sprint ${s.name}`}
ariaLabel={tr(`Sélectionner le sprint ${s.name}`, `Select sprint ${s.name}`)}
count={s.ticketCount}
selected={selectedSprintId === s.id}
onClick={() => pick(s)}
@ -223,7 +226,7 @@ function SprintPickerBody({
</ul>
{sprints !== null && ordered.length === 0 && !error && (
<p className="mt-2 text-sm text-muted">No sprints yet.</p>
<p className="mt-2 text-sm text-muted">{tr("Aucun sprint pour le moment.", "No sprints yet.")}</p>
)}
</div>
</div>

View File

@ -11,6 +11,7 @@
import { useEffect, useRef, useState } from "react";
import { Button, Spinner, cn } from "@/shared";
import { useI18n } from "@/i18n";
import { TicketViewportSelect } from "./TicketViewportSelect";
import { useTicketAssistant } from "./useTicketAssistant";
@ -23,6 +24,7 @@ export function TicketAssistantPanel({
projectId,
ticketRef,
}: TicketAssistantPanelProps) {
const { tr } = useI18n();
const vm = useTicketAssistant(projectId, ticketRef);
const [profileId, setProfileId] = useState("");
const [draft, setDraft] = useState("");
@ -49,7 +51,7 @@ export function TicketAssistantPanel({
// ── Session opener ──
<div className="flex flex-wrap items-center gap-2">
<TicketViewportSelect
aria-label="profil de l'assistant"
aria-label={tr("profil de l'assistant", "assistant profile")}
value={profileId}
disabled={vm.opening || vm.profiles.length === 0}
options={[
@ -57,8 +59,8 @@ export function TicketAssistantPanel({
value: "",
label:
vm.profiles.length === 0
? "Aucun profil disponible"
: "Choisir un profil IA…",
? tr("Aucun profil disponible", "No profile available")
: tr("Choisir un profil IA…", "Choose an AI profile…"),
},
...vm.profiles.map((profile) => ({
value: profile.id,
@ -73,7 +75,7 @@ export function TicketAssistantPanel({
loading={vm.opening}
onClick={() => void vm.open(profileId)}
>
Ouvrir la conversation
{tr("Ouvrir la conversation", "Open conversation")}
</Button>
</div>
) : (
@ -81,8 +83,7 @@ export function TicketAssistantPanel({
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted">
Conversation ouverte — l'assistant peut lire le projet et éditer ce
ticket.
{tr("Conversation ouverte — l'assistant peut lire le projet et éditer ce ticket.", "Conversation open — the assistant can read the project and edit this ticket.")}
</span>
<Button
size="sm"
@ -90,7 +91,7 @@ export function TicketAssistantPanel({
disabled={vm.busy}
onClick={() => void vm.close()}
>
Fermer la conversation
{tr("Fermer la conversation", "Close conversation")}
</Button>
</div>
@ -100,8 +101,7 @@ export function TicketAssistantPanel({
>
{vm.turns.length === 0 ? (
<p className="m-auto text-xs text-muted">
Démarrez la conversation en décrivant ce que l'assistant doit
faire sur ce ticket.
{tr("Démarrez la conversation en décrivant ce que l'assistant doit faire sur ce ticket.", "Start the conversation by describing what the assistant should do with this ticket.")}
</p>
) : (
vm.turns.map((turn, i) => (
@ -134,14 +134,14 @@ export function TicketAssistantPanel({
<div className="flex items-end gap-2">
<textarea
aria-label="message à l'assistant"
aria-label={tr("message à l'assistant", "message to the assistant")}
className={cn(
"min-h-10 flex-1 resize-none rounded-md bg-raised p-2 text-sm text-content",
"border border-border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
rows={2}
placeholder="Votre message…"
placeholder={tr("Votre message…", "Your message…")}
value={draft}
disabled={vm.busy}
onChange={(e) => setDraft(e.target.value)}
@ -158,7 +158,7 @@ export function TicketAssistantPanel({
loading={vm.busy}
onClick={submit}
>
Envoyer
{tr("Envoyer", "Send")}
</Button>
</div>
</div>

View File

@ -13,6 +13,7 @@ import { useEffect, useRef, useState } from "react";
import type { TicketLinkKind } from "@/domain";
import { Button, FloatingWindow, Input, Spinner, cn } from "@/shared";
import { useI18n } from "@/i18n";
import { useTicketDetail } from "./useTicketDetail";
import { useProjectAgents } from "./useProjectAgents";
import { TicketAssistantPanel } from "./TicketAssistantPanel";
@ -94,6 +95,7 @@ export function TicketDetail({
onClose,
onOpenRef,
}: TicketDetailProps) {
const { tr } = useI18n();
const vm = useTicketDetail(projectId, ticketRef);
const { agents, nameOf } = useProjectAgents(projectId);
const t = vm.ticket;
@ -191,7 +193,7 @@ export function TicketDetail({
async function copyDelegation() {
if (!t) return;
const ok = await copyToClipboard(
`Travaille sur le ticket ${t.ref} : ${t.title}`,
tr(`Travaille sur le ticket ${t.ref} : ${t.title}`, `Work on ticket ${t.ref}: ${t.title}`),
);
if (ok) {
setCopied(true);
@ -202,7 +204,7 @@ export function TicketDetail({
return (
<FloatingWindow
open
title={`ticket ${ticketRef}`}
title={`${tr("Ticket", "Ticket")} ${ticketRef}`}
size="xl"
onClose={requestClose}
>
@ -222,9 +224,9 @@ export function TicketDetail({
variant="ghost"
disabled={!t}
onClick={() => void copyDelegation()}
title="Copy a delegation prompt to hand this ticket to an agent"
title={tr("Copier un prompt de délégation pour confier ce ticket à un agent", "Copy a delegation prompt to hand this ticket to an agent")}
>
{copied ? "Copied" : "Delegation context"}
{copied ? tr("Copié", "Copied") : tr("Contexte de délégation", "Delegation context")}
</Button>
<Button
size="sm"
@ -232,9 +234,9 @@ export function TicketDetail({
disabled={!t || vm.busy}
onClick={() => setConfirmDelete(true)}
className="text-danger hover:text-danger"
title="Supprimer définitivement ce ticket"
title={tr("Supprimer définitivement ce ticket", "Permanently delete this ticket")}
>
Supprimer
{tr("Supprimer", "Delete")}
</Button>
</div>
</div>
@ -244,7 +246,7 @@ export function TicketDetail({
role="alert"
className="border-b border-warning/40 bg-warning/10 px-1 py-2 text-sm text-warning"
>
This ticket was modified elsewhere and reloaded. Re-apply your change.
{tr("Ce ticket a été modifié ailleurs et rechargé. Réappliquez votre modification.", "This ticket was modified elsewhere and reloaded. Re-apply your change.")}
</p>
)}
{vm.error && !vm.conflict && (
@ -259,21 +261,21 @@ export function TicketDetail({
{vm.busy && !t ? (
<div className="flex items-center gap-2 py-5 text-sm text-muted">
<Spinner size={14} />
<span>Loading ticket…</span>
<span>{tr("Chargement du ticket…", "Loading ticket…")}</span>
</div>
) : !t ? (
<div className="flex items-center justify-center py-10 text-sm text-muted">
Ticket not found.
{tr("Ticket introuvable.", "Ticket not found.")}
</div>
) : (
<div className="-mx-4">
{/* ── Fields (F3) ── */}
<Section title="Details">
<Section title={tr("Détails", "Details")}>
<p className="text-xs text-muted">
Créé par : {ticketActorLabel(t.createdBy, nameOf)}
{tr("Créé par", "Created by")}: {ticketActorLabel(t.createdBy, nameOf)}
</p>
<label className="text-xs font-medium text-muted" htmlFor="td-title">
Title
{tr("Titre", "Title")}
</label>
<Input
id="td-title"
@ -285,7 +287,7 @@ export function TicketDetail({
className="mt-2 text-xs font-medium text-muted"
htmlFor="td-desc"
>
Description
{tr("Description", "Description")}
</label>
<textarea
id="td-desc"
@ -308,7 +310,7 @@ export function TicketDetail({
void vm.updateFields({ title, description })
}
>
Save changes
{tr("Enregistrer les modifications", "Save changes")}
</Button>
{dirtyFields && (
<Button
@ -320,24 +322,24 @@ export function TicketDetail({
setDescription(t.description);
}}
>
Reset
{tr("Réinitialiser", "Reset")}
</Button>
)}
</div>
</Section>
{/* ── Status & priority (F3) ── */}
<Section title="Status & priority">
<Section title={tr("Statut et priorité", "Status & priority")}>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-xs text-muted">
Status
{tr("Statut", "Status")}
<TicketViewportSelect
aria-label="ticket status"
aria-label={tr("Statut du ticket", "Ticket status")}
value={t.status}
disabled={vm.busy}
options={TICKET_STATUSES.map((status) => ({
value: status,
label: statusLabel(status),
label: statusLabel(status, tr),
}))}
onChange={(next) =>
void vm.updateFields({
@ -347,14 +349,14 @@ export function TicketDetail({
/>
</label>
<label className="flex items-center gap-2 text-xs text-muted">
Priority
{tr("Priorité", "Priority")}
<TicketViewportSelect
aria-label="ticket priority"
aria-label={tr("Priorité du ticket", "Ticket priority")}
value={t.priority}
disabled={vm.busy}
options={TICKET_PRIORITIES.map((priority) => ({
value: priority,
label: priorityLabel(priority),
label: priorityLabel(priority, tr),
}))}
onChange={(next) =>
void vm.updateFields({
@ -369,11 +371,11 @@ export function TicketDetail({
{/* ── Carnet (F4) ── */}
<Section
title="Carnet"
hint="Markdown notebook scoped to this ticket only — separate from project memory. Saving replaces the whole carnet."
title={tr("Carnet", "Notes")}
hint={tr("Carnet Markdown propre à ce ticket — distinct de la mémoire du projet. L’enregistrement remplace tout le carnet.", "Markdown notebook scoped to this ticket only — separate from project memory. Saving replaces the whole notebook.")}
>
<textarea
aria-label="ticket carnet"
aria-label={tr("Carnet du ticket", "Ticket notes")}
className={cn(textareaClass, "min-h-40 font-mono")}
value={carnet}
onChange={(e) => setCarnet(e.target.value)}
@ -386,7 +388,7 @@ export function TicketDetail({
loading={vm.busy && dirtyCarnet}
onClick={() => void vm.saveCarnet(carnet)}
>
Save carnet
{tr("Enregistrer le carnet", "Save notes")}
</Button>
{dirtyCarnet && (
<Button
@ -395,21 +397,19 @@ export function TicketDetail({
disabled={vm.busy}
onClick={() => setCarnet(t.carnet ?? "")}
>
Reset
{tr("Réinitialiser", "Reset")}
</Button>
)}
</div>
</Section>
{/* ── Attachments (#108) ── */}
<Section title="Pièces jointes">
<Section title={tr("Pièces jointes", "Attachments")}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-muted">
{t.attachments.length === 0
? "Aucune pièce jointe."
: `${t.attachments.length} pièce${
t.attachments.length > 1 ? "s" : ""
} jointe${t.attachments.length > 1 ? "s" : ""}.`}
? tr("Aucune pièce jointe.", "No attachments.")
: tr(`${t.attachments.length} pièce${t.attachments.length > 1 ? "s" : ""} jointe${t.attachments.length > 1 ? "s" : ""}.`, `${t.attachments.length} attachment${t.attachments.length > 1 ? "s" : ""}.`)}
</p>
<Button
size="sm"
@ -417,7 +417,7 @@ export function TicketDetail({
loading={vm.busy}
onClick={() => void vm.attachFile()}
>
Joindre
{tr("Joindre", "Attach")}
</Button>
</div>
{t.attachments.length > 0 && (
@ -445,7 +445,7 @@ export function TicketDetail({
: "bg-raised text-muted",
)}
>
Résumé : {attachment.summarizedInCarnet ? "oui" : "non"}
{tr("Résumé", "Summarized")}: {attachment.summarizedInCarnet ? tr("oui", "yes") : tr("non", "no")}
</span>
{!attachment.summarizedInCarnet && (
<Button
@ -455,9 +455,9 @@ export function TicketDetail({
onClick={() =>
void vm.markAttachmentSummarized(attachment.id)
}
aria-label={`marquer résumé ${attachment.filename}`}
aria-label={tr(`Marquer ${attachment.filename} comme résumé`, `Mark ${attachment.filename} as summarized`)}
>
Marquer résumé
{tr("Marquer comme résumé", "Mark summarized")}
</Button>
)}
</div>
@ -468,9 +468,9 @@ export function TicketDetail({
</Section>
{/* ── Links (F5) ── */}
<Section title="Linked tickets">
<Section title={tr("Tickets liés", "Linked tickets")}>
{t.links.length === 0 ? (
<p className="text-xs text-muted">No linked tickets.</p>
<p className="text-xs text-muted">{tr("Aucun ticket lié.", "No linked tickets.")}</p>
) : (
<ul className="flex flex-col gap-1">
{t.links.map((l) => (
@ -479,7 +479,7 @@ export function TicketDetail({
className="flex items-center gap-2 text-sm"
>
<span className="text-xs text-muted">
{linkKindLabel(l.kind)}
{linkKindLabel(l.kind, tr)}
</span>
<TicketRef ticketRef={l.targetRef} onOpen={onOpenRef} />
<Button
@ -488,7 +488,7 @@ export function TicketDetail({
disabled={vm.busy}
onClick={() => void vm.unlink(l.targetRef, l.kind)}
>
Remove
{tr("Retirer", "Remove")}
</Button>
</li>
))}
@ -498,33 +498,33 @@ export function TicketDetail({
here, the target ticket is selected in the popup (no manual #id). */}
<div className="mt-1 flex flex-wrap items-center gap-2">
<TicketViewportSelect
aria-label="link kind"
aria-label={tr("Type de lien", "Link kind")}
value={linkKind}
disabled={vm.busy}
options={TICKET_LINK_KINDS.map((kind) => ({
value: kind,
label: linkKindLabel(kind),
label: linkKindLabel(kind, tr),
}))}
onChange={(next) => setLinkKind(next as TicketLinkKind)}
/>
<Button
size="sm"
aria-label="add link"
aria-label={tr("Ajouter un lien", "Add link")}
disabled={vm.busy}
onClick={() => setShowLinkPicker(true)}
>
Add link
{tr("Ajouter un lien", "Add link")}
</Button>
</div>
</Section>
{/* ── Assignments (F5) ── */}
<Section
title="Assigned agents"
hint="Only agents known to this project can be assigned."
title={tr("Agents assignés", "Assigned agents")}
hint={tr("Seuls les agents connus de ce projet peuvent être assignés.", "Only agents known to this project can be assigned.")}
>
{t.assignedAgentIds.length === 0 ? (
<p className="text-xs text-muted">No agents assigned.</p>
<p className="text-xs text-muted">{tr("Aucun agent assigné.", "No agents assigned.")}</p>
) : (
<ul className="flex flex-wrap gap-2">
{t.assignedAgentIds.map((id) => (
@ -535,7 +535,7 @@ export function TicketDetail({
{nameOf(id)}
<button
type="button"
aria-label={`unassign ${nameOf(id)}`}
aria-label={tr(`Désassigner ${nameOf(id)}`, `Unassign ${nameOf(id)}`)}
className="text-muted hover:text-danger disabled:opacity-50"
disabled={vm.busy}
onClick={() => void vm.assign(id, false)}
@ -548,7 +548,7 @@ export function TicketDetail({
)}
<div className="mt-1 flex items-center gap-2">
<TicketViewportSelect
aria-label="assign agent"
aria-label={tr("Assigner un agent", "Assign agent")}
value={assignPick}
disabled={vm.busy || assignable.length === 0}
options={[
@ -556,8 +556,8 @@ export function TicketDetail({
value: "",
label:
assignable.length === 0
? "No more agents"
: "Select an agent…",
? tr("Aucun autre agent", "No more agents")
: tr("Sélectionner un agent…", "Select an agent…"),
},
...assignable.map((agent) => ({
value: agent.id,
@ -574,7 +574,7 @@ export function TicketDetail({
if (ok) setAssignPick("");
}}
>
Assign
{tr("Assigner", "Assign")}
</Button>
</div>
</Section>
@ -583,7 +583,7 @@ export function TicketDetail({
<TicketPicker
open={showLinkPicker}
projectId={projectId}
title="Lier un ticket"
title={tr("Lier un ticket", "Link a ticket")}
selectionMode="single"
excludeRefs={[ticketRef, ...t.links.map((l) => l.targetRef)]}
onSelect={(result) => {
@ -602,11 +602,11 @@ export function TicketDetail({
{t && (
<div className="pointer-events-none sticky bottom-0 z-10 -mx-4 -mb-4 flex justify-end px-4 pb-4 pt-2">
<Button
aria-label="ouvrir l'assistant IA"
aria-label={tr("Ouvrir l’assistant IA", "Open AI assistant")}
className="pointer-events-auto shadow-lg"
onClick={() => setShowAssistant(true)}
>
🤖 Assistant IA
🤖 {tr("Assistant IA", "AI assistant")}
</Button>
</div>
)}
@ -614,7 +614,7 @@ export function TicketDetail({
{/* AI writing assistant, in a nested window (token 60) above this one. */}
<FloatingWindow
open={showAssistant}
title={`Assistant IA — ${ticketRef}`}
title={`${tr("Assistant IA", "AI assistant")} — ${ticketRef}`}
size="lg"
onClose={() => setShowAssistant(false)}
>
@ -626,45 +626,44 @@ export function TicketDetail({
<div
role="dialog"
aria-modal="true"
aria-label="unsaved changes"
aria-label={tr("Modifications non enregistrées", "Unsaved changes")}
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/55 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
<h4 className="text-sm font-semibold text-content">
Modifications non sauvegardées
{tr("Modifications non enregistrées", "Unsaved changes")}
</h4>
<p className="mt-2 text-sm text-muted">
Vous avez des modifications non sauvegardées. Voulez-vous les
sauvegarder avant de fermer&nbsp;?
{tr("Vous avez des modifications non enregistrées. Voulez-vous les enregistrer avant de fermer ?", "You have unsaved changes. Do you want to save them before closing?")}
</p>
<div className="mt-4 flex flex-wrap items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
aria-label="cancel close"
aria-label={tr("Annuler la fermeture", "Cancel close")}
disabled={vm.busy}
onClick={() => setConfirmClose(false)}
>
Annuler
{tr("Annuler", "Cancel")}
</Button>
<Button
size="sm"
variant="ghost"
aria-label="close without saving"
aria-label={tr("Fermer sans enregistrer", "Close without saving")}
disabled={vm.busy}
onClick={onClose}
className="text-danger hover:text-danger"
>
Fermer sans sauvegarder
{tr("Fermer sans enregistrer", "Close without saving")}
</Button>
<Button
size="sm"
aria-label="save and close"
aria-label={tr("Enregistrer et fermer", "Save and close")}
loading={vm.busy}
disabled={vm.busy}
onClick={() => void saveAndClose()}
>
Sauvegarder
{tr("Enregistrer", "Save")}
</Button>
</div>
</div>
@ -676,31 +675,30 @@ export function TicketDetail({
<div
role="dialog"
aria-modal="true"
aria-label="confirmer la suppression"
aria-label={tr("Confirmer la suppression", "Confirm deletion")}
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/55 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
<h4 className="text-sm font-semibold text-content">
Supprimer le ticket
{tr("Supprimer le ticket", "Delete ticket")}
</h4>
<p className="mt-2 text-sm text-muted">
Supprimer définitivement le ticket&nbsp;{ticketRef}&nbsp;? Cette
action est irréversible.
{tr(`Supprimer définitivement le ticket ${ticketRef} ? Cette action est irréversible.`, `Permanently delete ticket ${ticketRef}? This action cannot be undone.`)}
</p>
<div className="mt-4 flex flex-wrap items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
aria-label="cancel delete"
aria-label={tr("Annuler la suppression", "Cancel deletion")}
disabled={vm.busy}
onClick={() => setConfirmDelete(false)}
>
Annuler
{tr("Annuler", "Cancel")}
</Button>
<Button
size="sm"
variant="danger"
aria-label="confirm delete"
aria-label={tr("Confirmer la suppression", "Confirm deletion")}
loading={vm.busy}
disabled={vm.busy}
onClick={async () => {
@ -710,7 +708,7 @@ export function TicketDetail({
if (ok) setConfirmDelete(false);
}}
>
Supprimer
{tr("Supprimer", "Delete")}
</Button>
</div>
</div>

View File

@ -16,6 +16,7 @@
import type { TicketPriority, TicketStatus } from "@/domain";
import type { TicketListSort, TicketListSortField } from "@/ports";
import { Button, Input } from "@/shared";
import { useI18n } from "@/i18n";
import { TicketViewportSelect } from "./TicketViewportSelect";
import {
PriorityBadge,
@ -26,15 +27,7 @@ import {
statusLabel,
} from "./ticketMeta";
/** 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[];
const SORT_FIELDS: TicketListSortField[] = ["number", "priority", "status", "title"];
export interface TicketFacetsBarProps {
/** Current free-text search value (controlled). */
@ -71,26 +64,30 @@ export function TicketFacetsBar({
onToggleStatus,
onTogglePriority,
onClearFacets,
searchPlaceholder = "Search title/description…",
searchPlaceholder,
sort,
onSortChange,
}: TicketFacetsBarProps) {
const { tr } = useI18n();
const sortFieldLabel: Record<TicketListSortField, string> = {
number: tr("Numéro", "Number"), priority: tr("Priorité", "Priority"), status: tr("Statut", "Status"), title: tr("Titre", "Title"),
};
const hasFacets = statuses.length > 0 || priorities.length > 0;
return (
<div className="flex flex-col gap-2">
<Input
aria-label="search tickets"
placeholder={searchPlaceholder}
aria-label={tr("Rechercher des tickets", "Search tickets")}
placeholder={searchPlaceholder ?? tr("Rechercher dans le titre ou la description…", "Search title/description…")}
value={text}
onChange={(e) => onTextChange(e.target.value)}
/>
{/* Multi-select facets (ticket #12): OR within a facet, AND across; no
box ticked ⇒ that facet is unconstrained. */}
<fieldset
aria-label="filter by status"
aria-label={tr("Filtrer par statut", "Filter by status")}
className="flex flex-wrap items-center gap-x-3 gap-y-1"
>
<legend className="mr-1 text-xs font-medium text-muted">Statut</legend>
<legend className="mr-1 text-xs font-medium text-muted">{tr("Statut", "Status")}</legend>
{TICKET_STATUSES.map((s) => (
<label
key={s}
@ -98,7 +95,7 @@ export function TicketFacetsBar({
>
<input
type="checkbox"
aria-label={`filter status ${statusLabel(s)}`}
aria-label={`${tr("Filtrer le statut", "Filter status")} ${statusLabel(s, tr)}`}
className="accent-primary"
checked={statuses.includes(s)}
onChange={() => onToggleStatus(s)}
@ -108,11 +105,11 @@ export function TicketFacetsBar({
))}
</fieldset>
<fieldset
aria-label="filter by priority"
aria-label={tr("Filtrer par priorité", "Filter by priority")}
className="flex flex-wrap items-center gap-x-3 gap-y-1"
>
<legend className="mr-1 text-xs font-medium text-muted">
Priorité
{tr("Priorité", "Priority")}
</legend>
{TICKET_PRIORITIES.map((p) => (
<label
@ -121,7 +118,7 @@ export function TicketFacetsBar({
>
<input
type="checkbox"
aria-label={`filter priority ${priorityLabel(p)}`}
aria-label={`${tr("Filtrer la priorité", "Filter priority")} ${priorityLabel(p, tr)}`}
className="accent-primary"
checked={priorities.includes(p)}
onChange={() => onTogglePriority(p)}
@ -135,19 +132,19 @@ export function TicketFacetsBar({
client-side; this sort applies intra-group. */}
{onSortChange && (
<div
aria-label="sort tickets"
aria-label={tr("Trier les tickets", "Sort tickets")}
className="flex flex-wrap items-center gap-2"
>
<label className="flex items-center gap-1.5 text-xs text-muted">
Trier par
{tr("Trier par", "Sort by")}
<TicketViewportSelect
aria-label="sort tickets by"
aria-label={tr("Trier les tickets par", "Sort tickets by")}
value={sort?.field ?? ""}
options={[
{ value: "", label: "Par défaut" },
{ value: "", label: tr("Par défaut", "Default") },
...SORT_FIELDS.map((field) => ({
value: field,
label: SORT_FIELD_LABEL[field],
label: sortFieldLabel[field],
})),
]}
onChange={(next) => {
@ -161,7 +158,7 @@ export function TicketFacetsBar({
<Button
size="sm"
variant="ghost"
aria-label={`sort direction ${sort.direction === "asc" ? "ascending" : "descending"}`}
aria-label={tr(`Sens de tri ${sort.direction === "asc" ? "croissant" : "décroissant"}`, `Sort direction ${sort.direction === "asc" ? "ascending" : "descending"}`)}
onClick={() =>
onSortChange({
field: sort.field,
@ -169,7 +166,7 @@ export function TicketFacetsBar({
})
}
>
{sort.direction === "asc" ? "Croissant ↑" : "Décroissant ↓"}
{sort.direction === "asc" ? tr("Croissant ↑", "Ascending ↑") : tr("Décroissant ↓", "Descending ↓")}
</Button>
)}
</div>
@ -179,10 +176,10 @@ export function TicketFacetsBar({
<Button
size="sm"
variant="ghost"
aria-label="clear filters"
aria-label={tr("Effacer les filtres", "Clear filters")}
onClick={onClearFacets}
>
Effacer
{tr("Effacer", "Clear")}
</Button>
</div>
)}

View File

@ -86,7 +86,7 @@ describe("TicketPicker (#18)", () => {
expect(screen.getByText("Bravo task")).toBeTruthy();
expect(screen.getByText("Charlie chore")).toBeTruthy();
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${a.ref}`));
await waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
const result = onSelect.mock.calls[0][0] as TicketPickerResult;
@ -108,8 +108,8 @@ describe("TicketPicker (#18)", () => {
await screen.findByText("Bravo task");
expect(screen.queryByText("Alpha bug")).toBeNull();
// Excluded ref cannot be selected either.
expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull();
expect(screen.getByLabelText(`select ticket ${b.ref}`)).toBeTruthy();
expect(screen.queryByLabelText(`Sélectionner le ticket ${a.ref}`)).toBeNull();
expect(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`)).toBeTruthy();
});
it("filters by status facet (OR within, AND across)", async () => {
@ -119,19 +119,19 @@ describe("TicketPicker (#18)", () => {
await screen.findByText("Alpha bug");
// Tick "open" ⇒ only Alpha (open) remains; Bravo (closed) & Charlie (QA) go.
fireEvent.click(screen.getByLabelText("filter status Open"));
fireEvent.click(screen.getByLabelText("Filtrer le statut Ouvert"));
await waitFor(() => expect(screen.queryByText("Bravo task")).toBeNull());
expect(screen.getByText("Alpha bug")).toBeTruthy();
expect(screen.queryByText("Charlie chore")).toBeNull();
// Add priority "low" ⇒ (open) AND (low) ⇒ nothing (Alpha is high).
fireEvent.click(screen.getByLabelText("filter priority Low"));
fireEvent.click(screen.getByLabelText("Filtrer la priorité Faible"));
await waitFor(() =>
expect(screen.getByText("No matching tickets.")).toBeTruthy(),
expect(screen.getByText("Aucun ticket correspondant.")).toBeTruthy(),
);
// Clear facets ⇒ all three back.
fireEvent.click(screen.getByLabelText("clear filters"));
fireEvent.click(screen.getByLabelText("Effacer les filtres"));
await waitFor(() => expect(screen.getByText("Charlie chore")).toBeTruthy());
expect(screen.getByText("Alpha bug")).toBeTruthy();
expect(screen.getByText("Bravo task")).toBeTruthy();
@ -144,7 +144,7 @@ describe("TicketPicker (#18)", () => {
await screen.findByText("Alpha bug");
// The sort control is present in the picker too (shared TicketFacetsBar).
const sortBy = screen.getByLabelText("sort tickets by");
const sortBy = screen.getByLabelText("Trier les tickets par");
fireEvent.click(sortBy);
fireEvent.click(await screen.findByRole("option", { name: "Titre" }));
await waitFor(() =>
@ -164,7 +164,7 @@ describe("TicketPicker (#18)", () => {
});
// Flip to descending ⇒ Charlie now precedes Alpha.
fireEvent.click(screen.getByLabelText("sort direction ascending"));
fireEvent.click(screen.getByLabelText("Sens de tri croissant"));
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
field: "title",
@ -187,7 +187,7 @@ describe("TicketPicker (#18)", () => {
await screen.findByText("Alpha bug");
fireEvent.change(screen.getByLabelText("search tickets"), {
fireEvent.change(screen.getByLabelText("Rechercher des tickets"), {
target: { value: "charlie" },
});
@ -235,7 +235,7 @@ describe("TicketPicker (#18)", () => {
screen.getByRole("dialog", { name: "Choisir un ticket à lier" }),
).toBeTruthy();
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${a.ref}`));
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("gone"));
expect(onSelect).not.toHaveBeenCalled();
@ -254,35 +254,35 @@ describe("TicketPicker (#18)", () => {
await screen.findByText("Alpha bug");
const confirm = screen.getByLabelText(
"confirm ticket selection",
"Confirmer la sélection de tickets",
) as HTMLButtonElement;
// Empty selection ⇒ confirm disabled, count reads 0.
expect(confirm.disabled).toBe(true);
expect(screen.getByText("0 sélectionné")).toBeTruthy();
// Select two rows; the picker stays open and does not fire onSelect yet.
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket ${c.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${c.ref}`));
expect(onSelect).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
// Visual state: the toggled rows expose aria-checked.
expect(
screen.getByLabelText(`select ticket ${a.ref}`).getAttribute("aria-checked"),
screen.getByLabelText(`Sélectionner le ticket ${a.ref}`).getAttribute("aria-checked"),
).toBe("true");
expect(screen.getByText("2 sélectionnés")).toBeTruthy();
expect(confirm.textContent).toContain("2");
expect(confirm.disabled).toBe(false);
// Toggle one back off ⇒ count drops, still open.
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${a.ref}`));
expect(screen.getByText("1 sélectionné")).toBeTruthy();
expect(
screen.getByLabelText(`select ticket ${a.ref}`).getAttribute("aria-checked"),
screen.getByLabelText(`Sélectionner le ticket ${a.ref}`).getAttribute("aria-checked"),
).toBe("false");
// Re-select and confirm ⇒ onSelect receives the resolved array, then close.
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${a.ref}`));
fireEvent.click(confirm);
await waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
@ -301,7 +301,7 @@ describe("TicketPicker (#18)", () => {
await screen.findByText("Alpha bug");
const confirm = screen.getByLabelText(
"confirm ticket selection",
"Confirmer la sélection de tickets",
) as HTMLButtonElement;
expect(confirm.disabled).toBe(true);
// Clicking a disabled button is a no-op.
@ -318,13 +318,13 @@ describe("TicketPicker (#18)", () => {
// Excluded ref is not selectable.
await screen.findByText("Bravo task");
expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull();
expect(screen.getByLabelText(`select ticket ${b.ref}`)).toBeTruthy();
expect(screen.queryByLabelText(`Sélectionner le ticket ${a.ref}`)).toBeNull();
expect(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`)).toBeTruthy();
// Facets still narrow the list (Open ⇒ only Alpha, which is excluded ⇒ none).
fireEvent.click(screen.getByLabelText("filter status Open"));
fireEvent.click(screen.getByLabelText("Filtrer le statut Ouvert"));
await waitFor(() =>
expect(screen.getByText("No matching tickets.")).toBeTruthy(),
expect(screen.getByText("Aucun ticket correspondant.")).toBeTruthy(),
);
});
@ -334,7 +334,7 @@ describe("TicketPicker (#18)", () => {
selectionMode: "multi",
});
await screen.findByText("Alpha bug");
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${a.ref}`));
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);

View File

@ -30,6 +30,7 @@ import { useEffect, useRef, useState } from "react";
import type { TicketRef } from "@/domain";
import type { TicketListQuery } from "@/ports";
import { Button, Spinner, cn, zIndex } from "@/shared";
import { useI18n } from "@/i18n";
import { TicketFacetsBar } from "./TicketFacetsBar";
import { pickerFiltersKey } from "./ticketFilterPersistence";
import { PriorityBadge, StatusBadge, TicketRef as TicketRefBadge } from "./ticketMeta";
@ -79,8 +80,8 @@ const FOCUSABLE =
export function TicketPicker({
open,
projectId,
title = "Sélectionner un ticket",
confirmLabel = "Sélectionner",
title,
confirmLabel,
selectionMode = "single",
excludeRefs,
initialQuery,
@ -88,12 +89,13 @@ export function TicketPicker({
onSelect,
onClose,
}: TicketPickerProps) {
const { tr } = useI18n();
if (!open) return null;
return (
<TicketPickerBody
projectId={projectId}
title={title}
confirmLabel={confirmLabel}
title={title ?? tr("Sélectionner un ticket", "Select a ticket")}
confirmLabel={confirmLabel ?? tr("Sélectionner", "Select")}
selectionMode={selectionMode}
excludeRefs={excludeRefs}
initialQuery={initialQuery}
@ -132,6 +134,7 @@ function TicketPickerBody({
>
> &
Pick<TicketPickerProps, "excludeRefs" | "initialQuery">) {
const { tr } = useI18n();
const vm = useTicketSearch(projectId, {
initialQuery,
excludeRefs,
@ -254,10 +257,10 @@ function TicketPickerBody({
<Button
size="sm"
variant="ghost"
aria-label="close ticket picker"
aria-label={tr("Fermer le sélecteur de ticket", "Close ticket picker")}
onClick={onClose}
>
Close
{tr("Fermer", "Close")}
</Button>
</header>
@ -290,10 +293,10 @@ function TicketPickerBody({
{vm.busy && vm.rows.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>Loading tickets…</span>
<span>{tr("Chargement des tickets…", "Loading tickets…")}</span>
</div>
) : vm.rows.length === 0 ? (
<p className="text-sm text-muted">No matching tickets.</p>
<p className="text-sm text-muted">{tr("Aucun ticket correspondant.", "No matching tickets.")}</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{vm.rows.map((t) => {
@ -302,7 +305,7 @@ function TicketPickerBody({
<li key={t.ref} className="py-1.5 first:pt-0 last:pb-0">
<button
type="button"
aria-label={`select ticket ${t.ref}`}
aria-label={tr(`Sélectionner le ticket ${t.ref}`, `Select ticket ${t.ref}`)}
{...(isMulti
? { role: "checkbox", "aria-checked": checked }
: {})}
@ -353,7 +356,7 @@ function TicketPickerBody({
loading={vm.busy}
onClick={() => vm.loadMore()}
>
Load more
{tr("Charger plus", "Load more")}
</Button>
</div>
)}
@ -364,20 +367,20 @@ function TicketPickerBody({
{isMulti && (
<footer className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-4 py-3">
<span className="text-xs text-muted" aria-live="polite">
{selected.size} sélectionné{selected.size > 1 ? "s" : ""}
{tr(`${selected.size} sélectionné${selected.size > 1 ? "s" : ""}`, `${selected.size} selected`)}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
aria-label="cancel ticket selection"
aria-label={tr("Annuler la sélection de tickets", "Cancel ticket selection")}
onClick={onClose}
>
Annuler
{tr("Annuler", "Cancel")}
</Button>
<Button
size="sm"
aria-label="confirm ticket selection"
aria-label={tr("Confirmer la sélection de tickets", "Confirm ticket selection")}
loading={selecting}
disabled={selected.size === 0 || selecting}
onClick={() => void handleConfirm()}

View File

@ -11,6 +11,7 @@ import { useEffect, useMemo, useState } from "react";
import type { Sprint, TicketPriority, TicketStatus, TicketSummary } from "@/domain";
import { Button, Input, Panel, Spinner } from "@/shared";
import { useI18n } from "@/i18n";
import { useTickets } from "./useTickets";
import { useProjectAgents } from "./useProjectAgents";
import { SprintManager } from "./SprintManager";
@ -39,6 +40,7 @@ export interface TicketsPanelProps {
}
export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
const { tr } = useI18n();
const vm = useTickets(projectId);
const { agents, nameOf, loaded: agentsLoaded } = useProjectAgents(projectId);
@ -159,10 +161,8 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
setSelectedRefs(new Set(refs.filter((ref) => failed.has(ref))));
setBulkNotice(
failed.size === 0
? `Sprint mis à jour pour ${successCount} ticket${
successCount > 1 ? "s" : ""
}.`
: `Sprint mis à jour pour ${successCount}/${result.items.length} tickets. Les échecs restent sélectionnés.`,
? tr(`Sprint mis à jour pour ${successCount} ticket${successCount > 1 ? "s" : ""}.`, `Sprint updated for ${successCount} ticket${successCount > 1 ? "s" : ""}.`)
: tr(`Sprint mis à jour pour ${successCount}/${result.items.length} tickets. Les échecs restent sélectionnés.`, `Sprint updated for ${successCount}/${result.items.length} tickets. Failed items remain selected.`),
);
}
@ -177,7 +177,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
return (
<Panel
title="Tickets"
title={tr("Tickets", "Tickets")}
actions={
<div className="flex items-center gap-1">
<Button
@ -185,12 +185,12 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
variant="ghost"
onClick={() => setShowCreate((v) => !v)}
>
{showCreate ? "Cancel" : "New"}
{showCreate ? tr("Annuler", "Cancel") : tr("Nouveau", "New")}
</Button>
<Button
size="sm"
variant="ghost"
aria-label="manage sprints"
aria-label={tr("Gérer les sprints", "Manage sprints")}
onClick={() => setShowSprints(true)}
>
Sprints
@ -201,7 +201,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
onClick={() => void vm.refresh()}
loading={vm.busy}
>
Refresh
{tr("Actualiser", "Refresh")}
</Button>
</div>
}
@ -218,21 +218,20 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
{selectedVisibleRefs.length > 0 && (
<div
role="toolbar"
aria-label="bulk ticket actions"
aria-label={tr("Actions groupées sur les tickets", "Bulk ticket actions")}
className="mb-3 flex flex-wrap items-center gap-2 rounded-md border border-border bg-raised/70 px-2 py-2"
>
<span className="text-xs font-medium text-content">
{selectedVisibleRefs.length} sélectionné
{selectedVisibleRefs.length > 1 ? "s" : ""}
{tr(`${selectedVisibleRefs.length} sélectionné${selectedVisibleRefs.length > 1 ? "s" : ""}`, `${selectedVisibleRefs.length} selected`)}
</span>
<TicketViewportSelect
aria-label="Changer le statut"
aria-label={tr("Changer le statut", "Change status")}
value={bulkStatus}
options={[
{ value: "", label: "Changer le statut" },
{ value: "", label: tr("Changer le statut", "Change status") },
...TICKET_STATUSES.map((status) => ({
value: status,
label: statusLabel(status),
label: statusLabel(status, tr),
})),
]}
onChange={(next) => void applyBulkStatus(next as TicketStatus | "")}
@ -251,7 +250,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
disabled={vm.busy}
onClick={() => setConfirmBulkDelete(true)}
>
Supprimer
{tr("Supprimer", "Delete")}
</Button>
<Button
size="sm"
@ -259,7 +258,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
disabled={vm.busy}
onClick={() => setSelectedRefs(new Set())}
>
Annuler
{tr("Annuler", "Cancel")}
</Button>
</div>
)}
@ -279,18 +278,18 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
className="mb-3 flex flex-col gap-2 rounded-md border border-border bg-raised/50 p-2"
>
<Input
aria-label="new ticket title"
placeholder="Ticket title"
aria-label={tr("Titre du nouveau ticket", "New ticket title")}
placeholder={tr("Titre du ticket", "Ticket title")}
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
/>
<div className="flex items-center gap-2">
<TicketViewportSelect
aria-label="new ticket priority"
aria-label={tr("Priorité du nouveau ticket", "New ticket priority")}
value={newPriority}
options={TICKET_PRIORITIES.map((priority) => ({
value: priority,
label: priorityLabel(priority),
label: priorityLabel(priority, tr),
}))}
onChange={(next) => setNewPriority(next as TicketPriority)}
/>
@ -299,23 +298,23 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
size="sm"
disabled={!newTitle.trim() || vm.busy}
>
Create
{tr("Créer", "Create")}
</Button>
</div>
{/* Sprint field (#38): opens the SprintPicker popup; shows the choice
(or "Sans sprint"). */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Sprint</span>
<span className="text-xs text-muted">{tr("Sprint", "Sprint")}</span>
<Button
type="button"
size="sm"
variant="ghost"
aria-label="choose sprint for new ticket"
aria-label={tr("Choisir un sprint pour le nouveau ticket", "Choose sprint for new ticket")}
disabled={vm.busy}
onClick={() => setShowSprintPicker(true)}
className="border border-border"
>
{newSprint ? newSprint.name : "Sans sprint"}
{newSprint ? newSprint.name : tr("Sans sprint", "No sprint")}
</Button>
</div>
</form>
@ -343,10 +342,10 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
/>
<div className="flex flex-wrap items-center gap-2">
<TicketViewportSelect
aria-label="filter by assignee"
aria-label={tr("Filtrer par agent assigné", "Filter by assignee")}
value={vm.query.assignedAgentId ?? ""}
options={[
{ value: "", label: "All assignees" },
{ value: "", label: tr("Tous les agents assignés", "All assignees") },
...agents.map((agent) => ({
value: agent.id,
label: agent.name,
@ -360,11 +359,11 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
}
/>
<TicketViewportSelect
aria-label="filter by creator"
aria-label={tr("Filtrer par créateur", "Filter by creator")}
value={creatorFilterValue(vm.query.createdBy)}
options={[
{ value: "", label: "Tous créateurs" },
{ value: "user", label: "Utilisateur" },
{ value: "", label: tr("Tous les créateurs", "All creators") },
{ value: "user", label: tr("Utilisateur", "User") },
...agents.map((agent) => ({
value: `agent:${agent.id}`,
label: agent.name,
@ -386,10 +385,10 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
{vm.busy && vm.list === null ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>Loading tickets…</span>
<span>{tr("Chargement des tickets…", "Loading tickets…")}</span>
</div>
) : items.length === 0 ? (
<p className="text-sm text-muted">No tickets.</p>
<p className="text-sm text-muted">{tr("Aucun ticket.", "No tickets.")}</p>
) : (
<div className="flex flex-col gap-4">
{grouped.map((g) => (
@ -408,7 +407,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
))}
{noSprint.length > 0 && (
<SprintSection
heading="Sans sprint"
heading={tr("Sans sprint", "No sprint")}
count={noSprint.length}
tickets={noSprint}
sprints={vm.sprints}
@ -435,7 +434,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
})
}
>
Load more
{tr("Charger plus", "Load more")}
</Button>
</div>
)}
@ -454,7 +453,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
open={showSprintPicker}
projectId={projectId}
selectedSprintId={newSprint?.id ?? null}
title="Sprint du nouveau ticket"
title={tr("Sprint du nouveau ticket", "Sprint for new ticket")}
onSelect={(sprint) => setNewSprint(sprint)}
onClose={() => setShowSprintPicker(false)}
/>
@ -463,9 +462,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
open={showBulkSprintPicker}
projectId={projectId}
selectedSprintId={null}
title={`Sprint pour ${selectedVisibleRefs.length} ticket${
selectedVisibleRefs.length > 1 ? "s" : ""
}`}
title={tr(`Sprint pour ${selectedVisibleRefs.length} ticket${selectedVisibleRefs.length > 1 ? "s" : ""}`, `Sprint for ${selectedVisibleRefs.length} ticket${selectedVisibleRefs.length > 1 ? "s" : ""}`)}
onSelect={(sprint) => void applyBulkSprint(sprint)}
onClose={() => setShowBulkSprintPicker(false)}
/>
@ -474,34 +471,33 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
<div
role="dialog"
aria-modal="true"
aria-label="confirmer la suppression groupée"
aria-label={tr("Confirmer la suppression groupée", "Confirm bulk deletion")}
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-panel p-4 shadow-lg">
<p className="text-sm font-semibold text-content">
Supprimer {selectedVisibleRefs.length} ticket
{selectedVisibleRefs.length > 1 ? "s" : ""}&nbsp;?
{tr(`Supprimer ${selectedVisibleRefs.length} ticket${selectedVisibleRefs.length > 1 ? "s" : ""} ?`, `Delete ${selectedVisibleRefs.length} ticket${selectedVisibleRefs.length > 1 ? "s" : ""}?`)}
</p>
<p className="mt-1 text-sm text-muted">
Cette action supprimera les tickets sélectionnés du projet.
{tr("Cette action supprimera les tickets sélectionnés du projet.", "This will delete the selected tickets from the project.")}
</p>
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
aria-label="cancel bulk delete"
aria-label={tr("Annuler la suppression groupée", "Cancel bulk deletion")}
onClick={() => setConfirmBulkDelete(false)}
>
Annuler
{tr("Annuler", "Cancel")}
</Button>
<Button
size="sm"
variant="danger"
aria-label="confirm bulk delete"
aria-label={tr("Confirmer la suppression groupée", "Confirm bulk deletion")}
loading={vm.busy}
onClick={() => void applyBulkDelete()}
>
Supprimer
{tr("Supprimer", "Delete")}
</Button>
</div>
</div>
@ -537,8 +533,9 @@ function SprintSection({
selectedRefs: Set<string>;
onToggleSelected: (ref: string, selected: boolean) => void;
}) {
const { tr } = useI18n();
return (
<section aria-label={`sprint section ${heading}`}>
<section aria-label={`${tr("Section du sprint", "Sprint section")} ${heading}`}>
<h3 className="mb-1 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted">
{heading}
<span className="rounded-full bg-raised px-1.5 text-[10px] font-medium text-muted">
@ -551,7 +548,7 @@ function SprintSection({
<div className="flex items-start gap-2">
<input
type="checkbox"
aria-label={`select ticket row ${t.ref}`}
aria-label={tr(`Sélectionner le ticket ${t.ref}`, `Select ticket ${t.ref}`)}
checked={selectedRefs.has(t.ref)}
onChange={(e) => onToggleSelected(t.ref, e.currentTarget.checked)}
className="mt-1 h-4 w-4 shrink-0 rounded border-border bg-panel text-primary focus-visible:outline-none"
@ -571,7 +568,7 @@ function SprintSection({
</span>
)}
<span className="mt-0.5 block truncate text-xs text-muted">
Créé par : {ticketActorLabel(t.createdBy, nameOf)}
{tr("Créé par", "Created by")}: {ticketActorLabel(t.createdBy, nameOf)}
</span>
</button>
<span className="flex shrink-0 flex-col items-end gap-1">
@ -581,10 +578,10 @@ function SprintSection({
</div>
<div className="mt-1 pl-6">
<TicketViewportSelect
aria-label={`sprint for ${t.ref}`}
aria-label={tr(`Sprint de ${t.ref}`, `Sprint for ${t.ref}`)}
value={t.sprintId ?? ""}
options={[
{ value: "", label: "— No sprint —" },
{ value: "", label: tr("— Sans sprint —", "— No sprint —") },
...sprints.map((sprint) => ({
value: sprint.id,
label: sprint.name,

View File

@ -44,7 +44,7 @@ describe("ticket #29 — persisted list filters (useTickets)", () => {
});
renderPanel();
const search = (await screen.findByLabelText(
"search tickets",
"Rechercher des tickets",
)) as HTMLInputElement;
expect(search.value).toBe("restored-text");
});
@ -52,7 +52,7 @@ describe("ticket #29 — persisted list filters (useTickets)", () => {
it("persists a filter change (scoped to the list key)", async () => {
renderPanel();
const search = (await screen.findByLabelText(
"search tickets",
"Rechercher des tickets",
)) as HTMLInputElement;
fireEvent.change(search, { target: { value: "boom" } });
await waitFor(() => {
@ -115,7 +115,7 @@ describe("ticket #29 — persisted picker filters (TicketPicker)", () => {
prefs.write(pickerFiltersKey(PROJECT_ID), { text: "picker-pref" });
renderPicker();
const search = (await screen.findByLabelText(
"search tickets",
"Rechercher des tickets",
)) as HTMLInputElement;
expect(search.value).toBe("picker-pref");
});
@ -142,7 +142,7 @@ describe("ticket #29 — persisted picker filters (TicketPicker)", () => {
// Seed text so something is persisted; small limit forces a nextCursor.
renderPicker({ initialQuery: { text: "Alpha", limit: 2 } });
// Wait for the first page and its "Load more" (nextCursor present).
const loadMore = await screen.findByRole("button", { name: /load more/i });
const loadMore = await screen.findByRole("button", { name: /charger plus/i });
fireEvent.click(loadMore);
await screen.findByText("Alpha 0");

View File

@ -7,7 +7,10 @@
import type { ReactNode } from "react";
import type { TicketLinkKind, TicketPriority, TicketStatus } from "@/domain";
import { useI18n } from "@/i18n";
import { cn } from "@/shared";
type Tr = (french: string, english: string) => string;
const english: Tr = (_fr, en) => en;
export const TICKET_STATUSES: TicketStatus[] = [
"open",
@ -31,35 +34,35 @@ export const TICKET_LINK_KINDS: TicketLinkKind[] = [
"dependsOn",
];
export function statusLabel(status: TicketStatus): string {
export function statusLabel(status: TicketStatus, tr: Tr = english): string {
switch (status) {
case "open":
return "Open";
return tr("Ouvert", "Open");
case "inProgress":
return "In progress";
return tr("En cours", "In progress");
case "QA":
return "QA";
case "closed":
return "Closed";
return tr("Fermé", "Closed");
}
}
export function priorityLabel(priority: TicketPriority): string {
return priority.charAt(0).toUpperCase() + priority.slice(1);
export function priorityLabel(priority: TicketPriority, tr: Tr = english): string {
return { low: tr("Faible", "Low"), medium: tr("Moyenne", "Medium"), high: tr("Haute", "High"), critical: tr("Critique", "Critical") }[priority];
}
export function linkKindLabel(kind: TicketLinkKind): string {
export function linkKindLabel(kind: TicketLinkKind, tr: Tr = english): string {
switch (kind) {
case "relatesTo":
return "relates to";
return tr("en relation avec", "relates to");
case "blocks":
return "blocks";
return tr("bloque", "blocks");
case "blockedBy":
return "blocked by";
return tr("bloqué par", "blocked by");
case "duplicates":
return "duplicates";
return tr("duplique", "duplicates");
case "dependsOn":
return "depends on";
return tr("dépend de", "depends on");
}
}
@ -97,13 +100,15 @@ function badgeBase(className?: string): string {
}
export function StatusBadge({ status }: { status: TicketStatus }) {
return <span className={badgeBase(statusClass(status))}>{statusLabel(status)}</span>;
const { tr } = useI18n();
return <span className={badgeBase(statusClass(status))}>{statusLabel(status, tr)}</span>;
}
export function PriorityBadge({ priority }: { priority: TicketPriority }) {
const { tr } = useI18n();
return (
<span className={badgeBase(priorityClass(priority))}>
{priorityLabel(priority)}
{priorityLabel(priority, tr)}
</span>
);
}
@ -121,6 +126,7 @@ export function TicketRef({
onOpen?: (ref: string) => void;
className?: string;
}) {
const { tr } = useI18n();
const base = cn(
"inline-flex shrink-0 items-center rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-content",
className,
@ -129,7 +135,7 @@ export function TicketRef({
return (
<button
type="button"
aria-label={`open ticket ${ticketRef}`}
aria-label={tr(`Ouvrir le ticket ${ticketRef}`, `Open ticket ${ticketRef}`)}
className={cn(base, "transition-colors hover:bg-primary/15 hover:text-primary")}
onClick={() => onOpen(ticketRef)}
>

View File

@ -307,7 +307,7 @@ describe("TicketsView", () => {
it("shows the empty state then a created ticket", async () => {
renderView(ticket, system, agent);
expect(await screen.findByText("No tickets.")).toBeTruthy();
expect(await screen.findByText("Aucun ticket.")).toBeTruthy();
await ticket.create(PROJECT_ID, { title: "Live created" });
// The panel refreshes on the issueCreated event.
@ -319,21 +319,21 @@ describe("TicketsView", () => {
const setSpy = vi.spyOn(ticket, "setTicketSprint");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByRole("button", { name: "New" }));
fireEvent.change(screen.getByLabelText("new ticket title"), {
fireEvent.click(await screen.findByRole("button", { name: "Nouveau" }));
fireEvent.change(screen.getByLabelText("Titre du nouveau ticket"), {
target: { value: "With sprint" },
});
// Field defaults to "Sans sprint"; open the picker and pick Alpha.
const sprintField = screen.getByLabelText("choose sprint for new ticket");
const sprintField = screen.getByLabelText("Choisir un sprint pour le nouveau ticket");
expect(sprintField.textContent).toContain("Sans sprint");
fireEvent.click(sprintField);
fireEvent.click(await screen.findByLabelText("select sprint Alpha"));
fireEvent.click(await screen.findByLabelText("Sélectionner le sprint Alpha"));
expect(
screen.getByLabelText("choose sprint for new ticket").textContent,
screen.getByLabelText("Choisir un sprint pour le nouveau ticket").textContent,
).toContain("Alpha");
fireEvent.click(screen.getByRole("button", { name: "Create" }));
fireEvent.click(screen.getByRole("button", { name: "Créer" }));
// create → setTicketSprint(projectId, ref, "s1", version).
await waitFor(() => expect(setSpy).toHaveBeenCalledTimes(1));
@ -350,11 +350,11 @@ describe("TicketsView", () => {
const setSpy = vi.spyOn(ticket, "setTicketSprint");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByRole("button", { name: "New" }));
fireEvent.change(screen.getByLabelText("new ticket title"), {
fireEvent.click(await screen.findByRole("button", { name: "Nouveau" }));
fireEvent.change(screen.getByLabelText("Titre du nouveau ticket"), {
target: { value: "No sprint" },
});
fireEvent.click(screen.getByRole("button", { name: "Create" }));
fireEvent.click(screen.getByRole("button", { name: "Créer" }));
await waitFor(async () => {
const list = await ticket.list(PROJECT_ID);
@ -371,13 +371,13 @@ describe("TicketsView", () => {
});
renderView(ticket, system, agent);
fireEvent.click(await screen.findByRole("button", { name: "New" }));
fireEvent.change(screen.getByLabelText("new ticket title"), {
fireEvent.click(await screen.findByRole("button", { name: "Nouveau" }));
fireEvent.change(screen.getByLabelText("Titre du nouveau ticket"), {
target: { value: "Kept anyway" },
});
fireEvent.click(screen.getByLabelText("choose sprint for new ticket"));
fireEvent.click(await screen.findByLabelText("select sprint Alpha"));
fireEvent.click(screen.getByRole("button", { name: "Create" }));
fireEvent.click(screen.getByLabelText("Choisir un sprint pour le nouveau ticket"));
fireEvent.click(await screen.findByLabelText("Sélectionner le sprint Alpha"));
fireEvent.click(screen.getByRole("button", { name: "Créer" }));
// The ticket was created despite the failed assignment (no data lost)…
await waitFor(async () => {
@ -415,14 +415,14 @@ describe("TicketsView", () => {
// Tick two statuses → OR within the facet ⇒ open|closed ⇒ Alpha + Bravo,
// Charlie (QA) drops out.
fireEvent.click(screen.getByLabelText("filter status Open"));
fireEvent.click(screen.getByLabelText("filter status Closed"));
fireEvent.click(screen.getByLabelText("Filtrer le statut Ouvert"));
fireEvent.click(screen.getByLabelText("Filtrer le statut Fermé"));
await waitFor(() => expect(screen.queryByText("Charlie")).toBeNull());
expect(screen.getByText("Alpha")).toBeTruthy();
expect(screen.getByText("Bravo")).toBeTruthy();
// Add a priority → AND across facets ⇒ (open|closed) AND high ⇒ only Alpha.
fireEvent.click(screen.getByLabelText("filter priority High"));
fireEvent.click(screen.getByLabelText("Filtrer la priorité Haute"));
await waitFor(() => expect(screen.queryByText("Bravo")).toBeNull());
expect(screen.getByText("Alpha")).toBeTruthy();
@ -435,7 +435,7 @@ describe("TicketsView", () => {
);
// « Effacer » empties both facets ⇒ all three back.
fireEvent.click(screen.getByLabelText("clear filters"));
fireEvent.click(screen.getByLabelText("Effacer les filtres"));
await waitFor(() => expect(screen.getByText("Charlie")).toBeTruthy());
expect(screen.getByText("Bravo")).toBeTruthy();
expect(screen.getByText("Alpha")).toBeTruthy();
@ -453,7 +453,7 @@ describe("TicketsView", () => {
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined();
// Choose a field ⇒ ascending by default, relayed in the query.
fireEvent.click(screen.getByLabelText("sort tickets by"));
fireEvent.click(screen.getByLabelText("Trier les tickets par"));
fireEvent.click(await screen.findByRole("option", { name: "Titre" }));
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
@ -464,7 +464,7 @@ describe("TicketsView", () => {
// The direction toggle flips asc → desc.
fireEvent.click(
screen.getByLabelText("sort direction ascending"),
screen.getByLabelText("Sens de tri croissant"),
);
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
@ -474,7 +474,7 @@ describe("TicketsView", () => {
);
// Back to « Par défaut » ⇒ `sort` dropped from the query again.
fireEvent.click(screen.getByLabelText("sort tickets by"));
fireEvent.click(screen.getByLabelText("Trier les tickets par"));
fireEvent.click(await screen.findByRole("option", { name: "Par défaut" }));
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined(),
@ -496,10 +496,10 @@ describe("TicketsView", () => {
renderView(ticket, system, agent);
await screen.findByText("Created by user");
expect(screen.getByText("Créé par : Utilisateur")).toBeTruthy();
expect(screen.getByText("Créé par : Agent : CreatorAgent")).toBeTruthy();
expect(screen.getByText("Créé par: Utilisateur")).toBeTruthy();
expect(screen.getByText("Créé par: Agent : CreatorAgent")).toBeTruthy();
fireEvent.click(screen.getByLabelText("filter by creator"));
fireEvent.click(screen.getByLabelText("Filtrer par créateur"));
fireEvent.click(await screen.findByRole("option", { name: "CreatorAgent" }));
await waitFor(() =>
@ -513,10 +513,10 @@ describe("TicketsView", () => {
fireEvent.click(screen.getByText("Created by agent"));
const dialog = await screen.findByRole("dialog", {
name: `ticket ${agentTicket.ref}`,
name: `Ticket ${agentTicket.ref}`,
});
expect(
within(dialog).getByText("Créé par : Agent : CreatorAgent"),
within(dialog).getByText("Créé par: Agent : CreatorAgent"),
).toBeTruthy();
});
@ -527,8 +527,8 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("Editable"));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByLabelText("ticket status"));
fireEvent.click(await screen.findByRole("option", { name: "In progress" }));
fireEvent.click(within(dialog).getByLabelText("Statut du ticket"));
fireEvent.click(await screen.findByRole("option", { name: "En cours" }));
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
@ -544,10 +544,10 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("WithCarnet"));
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByLabelText("ticket carnet"), {
fireEvent.change(within(dialog).getByLabelText("Carnet du ticket"), {
target: { value: "## notes\nscoped body" },
});
fireEvent.click(within(dialog).getByText("Save carnet"));
fireEvent.click(within(dialog).getByText("Enregistrer le carnet"));
await waitFor(async () => {
const carnet = await ticket.readCarnet(PROJECT_ID, t.ref);
@ -560,7 +560,7 @@ describe("TicketsView", () => {
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Attachable"));
const dialog = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
const dialog = await screen.findByRole("dialog", { name: `Ticket ${t.ref}` });
expect(within(dialog).getByText("Pièces jointes")).toBeTruthy();
expect(within(dialog).getByText("Aucune pièce jointe.")).toBeTruthy();
@ -569,16 +569,16 @@ describe("TicketsView", () => {
expect(await within(dialog).findByText("mock-attachment.txt")).toBeTruthy();
expect(within(dialog).getByText(/text\/plain/)).toBeTruthy();
expect(within(dialog).getByText("Résumé : non")).toBeTruthy();
expect(within(dialog).getByText("Résumé: non")).toBeTruthy();
const attached = await ticket.read(PROJECT_ID, t.ref);
expect(attached.attachments).toHaveLength(1);
fireEvent.click(
within(dialog).getByLabelText("marquer résumé mock-attachment.txt"),
within(dialog).getByLabelText("Marquer mock-attachment.txt comme résumé"),
);
expect(await within(dialog).findByText("Résumé : oui")).toBeTruthy();
expect(await within(dialog).findByText("Résumé: oui")).toBeTruthy();
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.attachments[0].summarizedInCarnet).toBe(true);
@ -593,9 +593,9 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("Assignable"));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByLabelText("assign agent"));
fireEvent.click(within(dialog).getByLabelText("Assigner un agent"));
fireEvent.click(await screen.findByRole("option", { name: "Backend" }));
fireEvent.click(within(dialog).getByText("Assign"));
fireEvent.click(within(dialog).getByText("Assigner"));
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
@ -627,19 +627,19 @@ describe("TicketsView", () => {
expectedVersion: t.version,
});
fireEvent.change(within(dialog).getByLabelText("Title"), {
fireEvent.change(within(dialog).getByLabelText("Titre"), {
target: { value: "Racy edited" },
});
fireEvent.click(within(dialog).getByText("Save changes"));
fireEvent.click(within(dialog).getByText("Enregistrer les modifications"));
expect(
await within(dialog).findByText(/modified elsewhere and reloaded/i),
await within(dialog).findByText(/modifié ailleurs et rechargé/i),
).toBeTruthy();
// After the reload the detail field reflects the reloaded (title unchanged)
// ticket rather than the abandoned local edit.
await waitFor(() =>
expect(
(within(dialog).getByLabelText("Title") as HTMLInputElement)
(within(dialog).getByLabelText("Titre") as HTMLInputElement)
.value,
).toBe("Racy"),
);
@ -656,13 +656,13 @@ describe("TicketsView", () => {
const desc = within(dialog).getByLabelText("Description") as HTMLTextAreaElement;
fireEvent.change(desc, { target: { value: "draft in progress" } });
// Also edit the title to cover both draft fields.
const titleInput = within(dialog).getByLabelText("Title") as HTMLInputElement;
const titleInput = within(dialog).getByLabelText("Titre") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "Persisted (wip)" } });
// Change the priority — an immediate-apply mutation that re-fetches the ticket
// and bumps its version. The unsaved draft must survive.
fireEvent.click(within(dialog).getByLabelText("ticket priority"));
fireEvent.click(await screen.findByRole("option", { name: "High" }));
fireEvent.click(within(dialog).getByLabelText("Priorité du ticket"));
fireEvent.click(await screen.findByRole("option", { name: "Haute" }));
// The backend applied the priority bump…
await waitFor(async () => {
@ -676,7 +676,7 @@ describe("TicketsView", () => {
(within(dialog).getByLabelText("Description") as HTMLTextAreaElement).value,
).toBe("draft in progress");
expect(
(within(dialog).getByLabelText("Title") as HTMLInputElement).value,
(within(dialog).getByLabelText("Titre") as HTMLInputElement).value,
).toBe("Persisted (wip)");
// The description was never persisted.
const persisted = await ticket.read(PROJECT_ID, t.ref);
@ -698,34 +698,34 @@ describe("TicketsView", () => {
</DIProvider>,
);
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
const detail = await screen.findByRole("dialog", { name: `Ticket ${t.ref}` });
// Clean state → Close fires onClose directly, no confirmation dialog.
fireEvent.click(within(detail).getByText("Close"));
fireEvent.click(within(detail).getByText("Fermer"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog", { name: "unsaved changes" })).toBeNull();
expect(screen.queryByRole("dialog", { name: "Modifications non enregistrées" })).toBeNull();
// Now make the draft dirty and try to close again.
fireEvent.change(within(detail).getByLabelText("Description"), {
target: { value: "unsaved body" },
});
fireEvent.click(within(detail).getByText("Close"));
fireEvent.click(within(detail).getByText("Fermer"));
// The confirmation appears and onClose was NOT called again.
const confirm = await screen.findByRole("dialog", { name: "unsaved changes" });
const confirm = await screen.findByRole("dialog", { name: "Modifications non enregistrées" });
expect(onClose).toHaveBeenCalledTimes(1);
// "Annuler" dismisses the popup and keeps the editor open.
fireEvent.click(within(confirm).getByLabelText("cancel close"));
fireEvent.click(within(confirm).getByLabelText("Annuler la fermeture"));
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "unsaved changes" })).toBeNull(),
expect(screen.queryByRole("dialog", { name: "Modifications non enregistrées" })).toBeNull(),
);
expect(onClose).toHaveBeenCalledTimes(1);
// Re-open the confirmation and choose "Fermer sans sauvegarder".
fireEvent.click(within(detail).getByText("Close"));
const confirm2 = await screen.findByRole("dialog", { name: "unsaved changes" });
fireEvent.click(within(confirm2).getByLabelText("close without saving"));
fireEvent.click(within(detail).getByText("Fermer"));
const confirm2 = await screen.findByRole("dialog", { name: "Modifications non enregistrées" });
fireEvent.click(within(confirm2).getByLabelText("Fermer sans enregistrer"));
expect(onClose).toHaveBeenCalledTimes(2);
// The unsaved description was discarded (never persisted).
const fresh = await ticket.read(PROJECT_ID, t.ref);
@ -747,14 +747,14 @@ describe("TicketsView", () => {
</DIProvider>,
);
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
const detail = await screen.findByRole("dialog", { name: `Ticket ${t.ref}` });
fireEvent.change(within(detail).getByLabelText("Description"), {
target: { value: "final body" },
});
fireEvent.click(within(detail).getByText("Close"));
fireEvent.click(within(detail).getByText("Fermer"));
const confirm = await screen.findByRole("dialog", { name: "unsaved changes" });
fireEvent.click(within(confirm).getByLabelText("save and close"));
const confirm = await screen.findByRole("dialog", { name: "Modifications non enregistrées" });
fireEvent.click(within(confirm).getByLabelText("Enregistrer et fermer"));
// The change is persisted and the panel closes.
await waitFor(async () => {
@ -777,21 +777,21 @@ describe("TicketsView", () => {
renderView(ticket, system, agent);
// Sections appear in sprint `order`, then the "Sans sprint" bucket last.
await screen.findByRole("region", { name: "sprint section Sprint One" });
await screen.findByRole("region", { name: "Section du sprint Sprint One" });
const sections = screen
.getAllByRole("region")
.map((r) => r.getAttribute("aria-label"))
.filter((l) => l?.startsWith("sprint section"));
.filter((l) => l?.startsWith("Section du sprint"));
expect(sections).toEqual([
"sprint section Sprint One",
"sprint section Sprint Two",
"sprint section Sans sprint",
"Section du sprint Sprint One",
"Section du sprint Sprint Two",
"Section du sprint Sans sprint",
]);
// The loose ticket lives in the bucket, the others under their sprint.
const bucket = screen.getByRole("region", { name: "sprint section Sans sprint" });
const bucket = screen.getByRole("region", { name: "Section du sprint Sans sprint" });
expect(within(bucket).getByText("Loose")).toBeTruthy();
const one = screen.getByRole("region", { name: "sprint section Sprint One" });
const one = screen.getByRole("region", { name: "Section du sprint Sprint One" });
expect(within(one).getByText("In one")).toBeTruthy();
});
@ -803,12 +803,12 @@ describe("TicketsView", () => {
// It starts in the "Sans sprint" bucket.
const bucket = await screen.findByRole("region", {
name: "sprint section Sans sprint",
name: "Section du sprint Sans sprint",
});
expect(within(bucket).getByText("Movable")).toBeTruthy();
// Pick the sprint in the row selector → assign it.
fireEvent.click(screen.getByLabelText(`sprint for ${t.ref}`));
fireEvent.click(screen.getByLabelText(`Sprint de ${t.ref}`));
fireEvent.click(await screen.findByRole("option", { name: "Sprint One" }));
// The gateway recorded the membership…
@ -819,12 +819,12 @@ describe("TicketsView", () => {
// …and the UI regrouped it under Sprint One, emptying the bucket.
await waitFor(() => {
const one = screen.getByRole("region", {
name: "sprint section Sprint One",
name: "Section du sprint Sprint One",
});
expect(within(one).getByText("Movable")).toBeTruthy();
});
expect(
screen.queryByRole("region", { name: "sprint section Sans sprint" }),
screen.queryByRole("region", { name: "Section du sprint Sans sprint" }),
).toBeNull();
});
@ -834,15 +834,15 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("Deletable"));
const detail = await screen.findByRole("dialog", {
name: `ticket ${t.ref}`,
name: `Ticket ${t.ref}`,
});
// Open the confirmation, then confirm the deletion.
fireEvent.click(within(detail).getByText("Supprimer"));
const confirm = await screen.findByRole("dialog", {
name: "confirmer la suppression",
name: "Confirmer la suppression",
});
fireEvent.click(within(confirm).getByLabelText("confirm delete"));
fireEvent.click(within(confirm).getByLabelText("Confirmer la suppression"));
// The gateway removed the ticket…
await waitFor(() =>
@ -853,12 +853,12 @@ describe("TicketsView", () => {
// …the detail closed (via the `issueDeleted` event → `deleted` flag)…
await waitFor(() =>
expect(
screen.queryByRole("dialog", { name: `ticket ${t.ref}` }),
screen.queryByRole("dialog", { name: `Ticket ${t.ref}` }),
).toBeNull(),
);
// …and the row disappeared from the list (event-driven refresh).
await waitFor(() => expect(screen.queryByText("Deletable")).toBeNull());
expect(await screen.findByText("No tickets.")).toBeTruthy();
expect(await screen.findByText("Aucun ticket.")).toBeTruthy();
});
it("selects several rows and changes their status from the bulk action bar", async () => {
@ -869,10 +869,10 @@ describe("TicketsView", () => {
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
fireEvent.click(await screen.findByLabelText(`Sélectionner le ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`));
expect(screen.getByRole("toolbar", { name: "bulk ticket actions" })).toBeTruthy();
expect(screen.getByRole("toolbar", { name: "Actions groupées sur les tickets" })).toBeTruthy();
fireEvent.click(screen.getByLabelText("Changer le statut"));
fireEvent.click(await screen.findByRole("option", { name: "QA" }));
@ -883,7 +883,7 @@ describe("TicketsView", () => {
expect((await ticket.read(PROJECT_ID, a.ref)).status).toBe("QA");
expect((await ticket.read(PROJECT_ID, b.ref)).status).toBe("QA");
});
expect(screen.queryByRole("toolbar", { name: "bulk ticket actions" })).toBeNull();
expect(screen.queryByRole("toolbar", { name: "Actions groupées sur les tickets" })).toBeNull();
});
it("selects several rows and assigns their sprint from the bulk action bar (#112)", async () => {
@ -895,12 +895,12 @@ describe("TicketsView", () => {
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
fireEvent.click(await screen.findByLabelText(`Sélectionner le ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`));
const toolbar = screen.getByRole("toolbar", { name: "bulk ticket actions" });
const toolbar = screen.getByRole("toolbar", { name: "Actions groupées sur les tickets" });
fireEvent.click(within(toolbar).getByRole("button", { name: "Sprint" }));
fireEvent.click(await screen.findByLabelText("select sprint Target"));
fireEvent.click(await screen.findByLabelText("Sélectionner le sprint Target"));
await waitFor(() => {
expect(setSpy).toHaveBeenCalledWith(PROJECT_ID, a.ref, "s1", a.version);
@ -913,7 +913,7 @@ describe("TicketsView", () => {
expect(screen.getByRole("status").textContent).toMatch(
/Sprint mis à jour pour 2 tickets/i,
);
expect(screen.queryByRole("toolbar", { name: "bulk ticket actions" })).toBeNull();
expect(screen.queryByRole("toolbar", { name: "Actions groupées sur les tickets" })).toBeNull();
});
it("keeps failed refs selected after a partial bulk sprint assignment (#112)", async () => {
@ -932,11 +932,11 @@ describe("TicketsView", () => {
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
const toolbar = screen.getByRole("toolbar", { name: "bulk ticket actions" });
fireEvent.click(await screen.findByLabelText(`Sélectionner le ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`));
const toolbar = screen.getByRole("toolbar", { name: "Actions groupées sur les tickets" });
fireEvent.click(within(toolbar).getByRole("button", { name: "Sprint" }));
fireEvent.click(await screen.findByLabelText("select sprint Target"));
fireEvent.click(await screen.findByLabelText("Sélectionner le sprint Target"));
await waitFor(async () => {
expect((await ticket.read(PROJECT_ID, a.ref)).sprintId).toBe("s1");
@ -947,11 +947,11 @@ describe("TicketsView", () => {
);
expect(screen.getByRole("status").textContent).toMatch(/1\/2 tickets/i);
expect(
(screen.getByLabelText(`select ticket row ${b.ref}`) as HTMLInputElement)
(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`) as HTMLInputElement)
.checked,
).toBe(true);
expect(
(screen.getByLabelText(`select ticket row ${a.ref}`) as HTMLInputElement)
(screen.getByLabelText(`Sélectionner le ticket ${a.ref}`) as HTMLInputElement)
.checked,
).toBe(false);
});
@ -964,14 +964,14 @@ describe("TicketsView", () => {
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
fireEvent.click(await screen.findByLabelText(`Sélectionner le ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`));
fireEvent.click(screen.getByRole("button", { name: "Supprimer" }));
const confirm = await screen.findByRole("dialog", {
name: "confirmer la suppression groupée",
name: "Confirmer la suppression groupée",
});
fireEvent.click(within(confirm).getByLabelText("confirm bulk delete"));
fireEvent.click(within(confirm).getByLabelText("Confirmer la suppression groupée"));
await waitFor(() =>
expect(bulkSpy).toHaveBeenCalledWith(PROJECT_ID, [a.ref, b.ref]),
@ -988,24 +988,24 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("Keeper"));
const detail = await screen.findByRole("dialog", {
name: `ticket ${t.ref}`,
name: `Ticket ${t.ref}`,
});
fireEvent.click(within(detail).getByText("Supprimer"));
const confirm = await screen.findByRole("dialog", {
name: "confirmer la suppression",
name: "Confirmer la suppression",
});
fireEvent.click(within(confirm).getByLabelText("cancel delete"));
fireEvent.click(within(confirm).getByLabelText("Annuler la suppression"));
// The confirmation is dismissed, delete was never called, ticket stays.
await waitFor(() =>
expect(
screen.queryByRole("dialog", { name: "confirmer la suppression" }),
screen.queryByRole("dialog", { name: "Confirmer la suppression" }),
).toBeNull(),
);
expect(delSpy).not.toHaveBeenCalled();
expect(
screen.getByRole("dialog", { name: `ticket ${t.ref}` }),
screen.getByRole("dialog", { name: `Ticket ${t.ref}` }),
).toBeTruthy();
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.title).toBe("Keeper");
@ -1018,7 +1018,7 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("Delegate me"));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByText("Delegation context"));
fireEvent.click(within(dialog).getByText("Contexte de délégation"));
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith(
@ -1044,12 +1044,12 @@ describe("TicketsView", () => {
</DIProvider>,
);
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
const detail = await screen.findByRole("dialog", { name: `Ticket ${t.ref}` });
// The assistant is opened from the prominent bottom-right floating button;
// it is not inline anymore (#17).
expect(within(detail).queryByLabelText("profil de l'assistant")).toBeNull();
fireEvent.click(within(detail).getByLabelText("ouvrir l'assistant IA"));
fireEvent.click(within(detail).getByLabelText("Ouvrir l’assistant IA"));
// It opens in its own nested window.
const assistant = await screen.findByRole("dialog", {
@ -1117,18 +1117,18 @@ describe("TicketsView", () => {
</DIProvider>,
);
const detail = await screen.findByRole("dialog", { name: `ticket ${a.ref}` });
const detail = await screen.findByRole("dialog", { name: `Ticket ${a.ref}` });
// The old manual "#id" input is gone.
expect(within(detail).queryByLabelText("link target ref")).toBeNull();
// Choose the link kind, then open the picker and select the target ticket.
fireEvent.click(within(detail).getByLabelText("link kind"));
fireEvent.click(await screen.findByRole("option", { name: "blocks" }));
fireEvent.click(within(detail).getByLabelText("add link"));
fireEvent.click(within(detail).getByLabelText("Type de lien"));
fireEvent.click(await screen.findByRole("option", { name: "bloque" }));
fireEvent.click(within(detail).getByLabelText("Ajouter un lien"));
// The picker excludes the ticket itself; the target is offered.
expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull();
fireEvent.click(await screen.findByLabelText(`select ticket ${b.ref}`));
expect(screen.queryByLabelText(`Sélectionner le ticket ${a.ref}`)).toBeNull();
fireEvent.click(await screen.findByLabelText(`Sélectionner le ticket ${b.ref}`));
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, a.ref);
@ -1153,27 +1153,27 @@ describe("SprintManager (#11)", () => {
renderView(ticket, system, agent);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "manage sprints" }),
screen.getByRole("button", { name: /gérer les sprints/i }),
).toBeTruthy(),
);
fireEvent.click(screen.getByRole("button", { name: "manage sprints" }));
return screen.findByRole("dialog", { name: "manage sprints" });
fireEvent.click(screen.getByRole("button", { name: /gérer les sprints/i }));
return screen.findByRole("dialog", { name: /gérer les sprints/i });
}
it("creates a sprint", async () => {
const dialog = await openManager();
fireEvent.change(within(dialog).getByLabelText("new sprint name"), {
fireEvent.change(within(dialog).getByLabelText("nom du nouveau sprint"), {
target: { value: "Alpha" },
});
fireEvent.click(within(dialog).getByText("Create sprint"));
fireEvent.click(within(dialog).getByText("Créer le sprint"));
await waitFor(async () => {
const sprints = await ticket.listSprints(PROJECT_ID);
expect(sprints.map((s) => s.name)).toEqual(["Alpha"]);
});
expect(
await within(dialog).findByLabelText("sprint row Alpha"),
await within(dialog).findByLabelText("ligne du sprint Alpha"),
).toBeTruthy();
});
@ -1182,17 +1182,17 @@ describe("SprintManager (#11)", () => {
// Leave the name field empty and create → default "Sprint N" (here N=1).
expect(
(within(dialog).getByLabelText("new sprint name") as HTMLInputElement)
(within(dialog).getByLabelText("nom du nouveau sprint") as HTMLInputElement)
.value,
).toBe("");
fireEvent.click(within(dialog).getByText("Create sprint"));
fireEvent.click(within(dialog).getByText("Créer le sprint"));
await waitFor(async () => {
const sprints = await ticket.listSprints(PROJECT_ID);
expect(sprints.map((s) => s.name)).toEqual(["Sprint 1"]);
});
expect(
await within(dialog).findByLabelText("sprint row Sprint 1"),
await within(dialog).findByLabelText("ligne du sprint Sprint 1"),
).toBeTruthy();
});
@ -1201,10 +1201,10 @@ describe("SprintManager (#11)", () => {
const dialog = await openManager();
fireEvent.change(
await within(dialog).findByLabelText("rename sprint Old"),
await within(dialog).findByLabelText("renommer le sprint Old"),
{ target: { value: "New" } },
);
fireEvent.click(within(dialog).getByLabelText("save sprint Old"));
fireEvent.click(within(dialog).getByLabelText("enregistrer le sprint Old"));
await waitFor(async () => {
const [s] = await ticket.listSprints(PROJECT_ID);
@ -1218,7 +1218,7 @@ describe("SprintManager (#11)", () => {
const dialog = await openManager();
fireEvent.click(
await within(dialog).findByLabelText("move sprint One down"),
await within(dialog).findByLabelText("descendre le sprint One"),
);
await waitFor(async () => {
@ -1236,14 +1236,14 @@ describe("SprintManager (#11)", () => {
const dialog = await openManager();
fireEvent.click(
await within(dialog).findByLabelText("delete sprint Doomed"),
await within(dialog).findByLabelText("supprimer le sprint Doomed"),
);
// The confirmation explains tickets are unassigned, not deleted.
const confirm = await screen.findByRole("dialog", {
name: "delete sprint confirmation",
name: "confirmation de suppression du sprint",
});
expect(confirm.textContent).toMatch(/ne seront pas supprimés/i);
fireEvent.click(within(confirm).getByLabelText("confirm delete sprint"));
fireEvent.click(within(confirm).getByLabelText("confirmer la suppression du sprint"));
await waitFor(async () => {
expect(await ticket.listSprints(PROJECT_ID)).toHaveLength(0);
@ -1263,13 +1263,16 @@ describe("SprintManager (#11)", () => {
// Open the add-ticket picker for the Target sprint (multi-select).
fireEvent.click(
await within(dialog).findByLabelText("add ticket to sprint Target"),
await within(dialog).findByLabelText("ajouter un ticket au sprint Target"),
);
const picker = await screen.findByRole("dialog", {
name: /Ajouter des tickets au sprint/,
});
// The picker toggles rows without assigning; confirm applies the batch.
fireEvent.click(await screen.findByLabelText(`select ticket ${t1.ref}`));
fireEvent.click(await screen.findByLabelText(`select ticket ${t2.ref}`));
fireEvent.click(screen.getByLabelText("confirm ticket selection"));
fireEvent.click(await within(picker).findByLabelText(`Sélectionner le ticket ${t1.ref}`));
fireEvent.click(await within(picker).findByLabelText(`Sélectionner le ticket ${t2.ref}`));
fireEvent.click(within(picker).getByLabelText("Confirmer la sélection de tickets"));
await waitFor(async () => {
expect((await ticket.read(PROJECT_ID, t1.ref)).sprintId).toBe("s1");
@ -1285,15 +1288,18 @@ describe("SprintManager (#11)", () => {
const dialog = await openManager();
fireEvent.click(
await within(dialog).findByLabelText("add ticket to sprint Target"),
await within(dialog).findByLabelText("ajouter un ticket au sprint Target"),
);
const picker = await screen.findByRole("dialog", {
name: /Ajouter des tickets au sprint/,
});
// The backlog ticket is offered; the one already in the sprint is excluded.
expect(
await screen.findByLabelText(`select ticket ${backlog.ref}`),
await within(picker).findByLabelText(`Sélectionner le ticket ${backlog.ref}`),
).toBeTruthy();
expect(
screen.queryByLabelText(`select ticket ${member.ref}`),
within(picker).queryByLabelText(`Sélectionner le ticket ${member.ref}`),
).toBeNull();
});
@ -1305,7 +1311,7 @@ describe("SprintManager (#11)", () => {
const dialog = await openManager();
fireEvent.click(
await within(dialog).findByLabelText(`remove ${t.ref} from sprint`),
await within(dialog).findByLabelText(`retirer ${t.ref} du sprint`),
);
await waitFor(async () => {
@ -1337,16 +1343,16 @@ describe("SprintManager (#11)", () => {
await seedSprintWithOpenAndClosed();
const dialog = await openManager();
const row = await within(dialog).findByLabelText("sprint row Holder");
const row = await within(dialog).findByLabelText("ligne du sprint Holder");
// Both the open and the closed ticket are listed (closed is NOT hidden).
expect(within(row).getByText("Open member")).toBeTruthy();
expect(within(row).getByText("Closed member")).toBeTruthy();
// Each row carries its status + priority badge (scoped to the sprint row so
// the facet-bar labels don't collide).
expect(within(row).getByText("Closed")).toBeTruthy();
expect(within(row).getByText("Open")).toBeTruthy();
expect(within(row).getByText("High")).toBeTruthy();
expect(within(row).getByText("Low")).toBeTruthy();
expect(within(row).getByText("Fermé")).toBeTruthy();
expect(within(row).getByText("Ouvert")).toBeTruthy();
expect(within(row).getByText("Haute")).toBeTruthy();
expect(within(row).getByText("Faible")).toBeTruthy();
});
it("filters the sprint tickets by status via its own facets bar (#37)", async () => {
@ -1357,14 +1363,14 @@ describe("SprintManager (#11)", () => {
expect(within(dialog).getByText("Closed member")).toBeTruthy();
// Constrain the sprint view to Closed only → the open ticket drops out.
fireEvent.click(within(dialog).getByLabelText("filter status Closed"));
fireEvent.click(within(dialog).getByLabelText("Filtrer le statut Fermé"));
await waitFor(() =>
expect(within(dialog).queryByText("Open member")).toBeNull(),
);
expect(within(dialog).getByText("Closed member")).toBeTruthy();
// Clearing the facet brings the open ticket back.
fireEvent.click(within(dialog).getByLabelText("filter status Closed"));
fireEvent.click(within(dialog).getByLabelText("Filtrer le statut Fermé"));
await waitFor(() =>
expect(within(dialog).getByText("Open member")).toBeTruthy(),
);
@ -1376,20 +1382,20 @@ describe("SprintManager (#11)", () => {
renderView(ticket, system, agent);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "manage sprints" }),
screen.getByRole("button", { name: /gérer les sprints/i }),
).toBeTruthy(),
);
// Constrain the MAIN Tickets panel to Open only → the closed ticket leaves
// the main list (there is a single main facets bar on screen at this point).
fireEvent.click(screen.getByLabelText("filter status Open"));
fireEvent.click(screen.getByLabelText("Filtrer le statut Ouvert"));
await waitFor(() => expect(screen.queryByText("Closed member")).toBeNull());
// Open the sprint manager: its own query ignores the main filter, so the
// closed ticket is still shown inside its sprint.
fireEvent.click(screen.getByRole("button", { name: "manage sprints" }));
const dialog = await screen.findByRole("dialog", { name: "manage sprints" });
const row = await within(dialog).findByLabelText("sprint row Holder");
fireEvent.click(screen.getByRole("button", { name: /gérer les sprints/i }));
const dialog = await screen.findByRole("dialog", { name: /gérer les sprints/i });
const row = await within(dialog).findByLabelText("ligne du sprint Holder");
expect(await within(row).findByText("Closed member")).toBeTruthy();
expect(within(row).getByText("Open member")).toBeTruthy();
});