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:
@ -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} » ?
|
||||
{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 : ils seront
|
||||
simplement désassignés et retourneront au backlog « Sans
|
||||
sprint ».
|
||||
{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)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ?
|
||||
{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 {ticketRef} ? 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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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" : ""} ?
|
||||
{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,
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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)}
|
||||
>
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user