Merge feature/ticket86-web-tickets-sprints-ui into develop

Ticket #86, lot 2 : surface tickets/sprints pour le workspace web, avec fix
QA sur le changement/retrait de sprint sur un ticket. QA vert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 07:44:51 +02:00
12 changed files with 2235 additions and 1 deletions

View File

@ -36,4 +36,7 @@ export {
statusLabel,
priorityLabel,
linkKindLabel,
TICKET_STATUSES,
TICKET_PRIORITIES,
TICKET_LINK_KINDS,
} from "./ticketMeta";

View File

@ -48,6 +48,12 @@ export interface TicketDetailViewModel {
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
assign: (agentId: string, assigned: boolean) => Promise<boolean>;
/**
* Changes this ticket's sprint membership, or clears it with
* `sprintId === null` (ticket #86 — web sprint control in the detail view).
* Routes to `ticket_assign_sprint`/`ticket_unassign_sprint` via the gateway.
*/
setSprint: (sprintId: string | null) => Promise<boolean>;
/**
* Deletes this ticket (ticket #6). Returns `true` on success. The removal from
* lists and the closing of this surface flow from the resulting `issueDeleted`
@ -205,6 +211,12 @@ export function useTicketDetail(
[run, gateway, projectId, ref],
);
const setSprint: TicketDetailViewModel["setSprint"] = useCallback(
(sprintId) =>
run((version) => gateway.setTicketSprint(projectId, ref, sprintId, version)),
[run, gateway, projectId, ref],
);
const remove: TicketDetailViewModel["remove"] = useCallback(async () => {
setBusy(true);
setError(null);
@ -234,6 +246,7 @@ export function useTicketDetail(
link,
unlink,
assign,
setSprint,
remove,
};
}

View File

@ -29,6 +29,8 @@ import { Button, Panel, Spinner, cn } from "@/shared";
import { WebAgentCell } from "./WebAgentCell";
import { useLiveReconnect } from "./useLiveReconnect";
import { useLiveConnectionState } from "./useLiveConnectionState";
import { WebTicketsView } from "./tickets/WebTicketsView";
import { WebSprintsView } from "./tickets/WebSprintsView";
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
@ -118,12 +120,109 @@ export function WebWorkspace() {
)}
{openId && (
<LiveProjectPanel projectId={openId} root={openRoot} />
<ProjectTabs projectId={openId} root={openRoot} />
)}
</div>
);
}
type ProjectTab = "live" | "tickets" | "sprints";
const PROJECT_TABS: { id: ProjectTab; label: string }[] = [
{ id: "live", label: "Live" },
{ id: "tickets", label: "Tickets" },
{ id: "sprints", label: "Sprints" },
];
/**
* Project navigation for the web workspace (ticket #86): `Live` / `Tickets` /
* `Sprints`, a compact tablist above the project surfaces — never the desktop
* dock/layout-grid/floating-window shell (carnet #86). Each tab keeps its own
* data (own hook instance), so switching away and back re-fetches fresh rather
* than caching stale state across tabs.
*/
function ProjectTabs({ projectId, root }: { projectId: string; root: string | null }) {
const [tab, setTab] = useState<ProjectTab>("live");
// Set by the Sprints tab's "Voir tickets"; consumed once by the Tickets tab.
const [focusSprintId, setFocusSprintId] = useState<string | null>(null);
return (
<div className="mt-2 flex flex-col gap-3">
<div
role="tablist"
aria-label="Navigation du projet"
className="flex gap-1 overflow-x-auto border-b border-border"
>
{PROJECT_TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`web-project-tab-${t.id}`}
aria-selected={tab === t.id}
aria-controls={`web-project-tabpanel-${t.id}`}
tabIndex={tab === t.id ? 0 : -1}
onClick={() => setTab(t.id)}
onKeyDown={(e) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const i = PROJECT_TABS.findIndex((x) => x.id === tab);
const next =
e.key === "ArrowRight"
? (i + 1) % PROJECT_TABS.length
: (i - 1 + PROJECT_TABS.length) % PROJECT_TABS.length;
setTab(PROJECT_TABS[next].id);
}}
className={cn(
"min-h-[32px] shrink-0 rounded-t-md border-b-2 px-3 py-1.5 text-sm font-medium transition-colors",
tab === t.id
? "border-primary text-content"
: "border-transparent text-muted hover:text-content",
)}
>
{t.label}
</button>
))}
</div>
<div
role="tabpanel"
id="web-project-tabpanel-live"
aria-labelledby="web-project-tab-live"
hidden={tab !== "live"}
>
{tab === "live" && <LiveProjectPanel projectId={projectId} root={root} />}
</div>
<div
role="tabpanel"
id="web-project-tabpanel-tickets"
aria-labelledby="web-project-tab-tickets"
hidden={tab !== "tickets"}
>
{tab === "tickets" && (
<WebTicketsView projectId={projectId} focusSprintId={focusSprintId} />
)}
</div>
<div
role="tabpanel"
id="web-project-tabpanel-sprints"
aria-labelledby="web-project-tab-sprints"
hidden={tab !== "sprints"}
>
{tab === "sprints" && (
<WebSprintsView
projectId={projectId}
onViewSprintTickets={(sprintId) => {
setFocusSprintId(sprintId);
setTab("tickets");
}}
/>
)}
</div>
</div>
);
}
/**
* Global reconnection banner (F6). The F3 terminal path writes a "déconnecté"
* notice into xterm, but live-only surfaces (no terminal open) had no visible

View File

@ -0,0 +1,78 @@
/**
* Destructive-action confirmation for the web tickets/sprints surface (ticket
* #86). Local to this feature, like `AppExitConfirmDialog` and
* `features/devices/ConfirmDialog` are local to theirs — a shared dialog
* primitive is a design-system decision outside this lot.
*
* `role="alertdialog"`, focus on `Annuler` by default, `Échap` cancels, no
* dismiss-on-outside-click (a destructive confirmation should not be dismissed
* ambiguously), per carnet #86's accessibility section.
*/
import { useEffect, useRef } from "react";
import { Button, zIndex } from "@/shared";
export interface WebConfirmDialogProps {
title: string;
body: string;
confirmLabel: string;
busy?: boolean;
onConfirm: () => void | Promise<void>;
onCancel: () => void;
}
export function WebConfirmDialog({
title,
body,
confirmLabel,
busy = false,
onConfirm,
onCancel,
}: WebConfirmDialogProps) {
const cancelRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelRef.current?.focus();
}, []);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onCancel();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onCancel]);
return (
<div
className="fixed inset-0 flex items-center justify-center bg-black/50 p-4"
style={{ zIndex: zIndex.toast }}
>
<div
role="alertdialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-sm flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl"
>
<h3 className="text-sm font-semibold text-content">{title}</h3>
<p className="text-sm text-muted">{body}</p>
<div className="flex justify-end gap-2">
<Button ref={cancelRef} size="sm" variant="ghost" disabled={busy} onClick={onCancel}>
Annuler
</Button>
<Button
size="sm"
variant="danger"
loading={busy}
disabled={busy}
onClick={() => void onConfirm()}
>
{confirmLabel}
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,311 @@
/**
* "Sprints" tab of the web workspace (ticket #86): create, rename, reorder
* (Monter/Descendre — no drag-and-drop required) and delete sprints, plus
* add/remove tickets via the mobile-adapted ticket picker. A dedicated surface
* — not an overlay above the ticket list like the desktop `SprintManager` —
* reachable directly from the project tab bar.
*
* Reuses `useTickets` (sprint CRUD) and `useTicketSearch` (to resolve which
* tickets belong to a sprint client-side, exactly like the desktop
* `SprintManager` — `ticket_list` has no server-side `sprintId` filter). The
* per-sprint ticket list also lets the user remove a ticket from the sprint
* (`assignSprint(ref, null)`, which routes to `ticket_unassign_sprint`) — the
* symmetric counterpart of "Ajouter tickets" (QA #86 fix).
*/
import { useState } from "react";
import type { Sprint, TicketSummary } from "@/domain";
import { useTickets, useTicketSearch, type TicketsViewModel } from "@/features/tickets";
import { Button, Input, Panel, Spinner } from "@/shared";
import { WebConfirmDialog } from "./WebConfirmDialog";
import { WebTicketPickerSheet } from "./WebTicketPickerSheet";
export interface WebSprintsViewProps {
projectId: string;
/** Switches to the Tickets tab, optionally pre-filtered to one sprint. */
onViewSprintTickets: (sprintId: string) => void;
}
export function WebSprintsView({ projectId, onViewSprintTickets }: WebSprintsViewProps) {
const vm = useTickets(projectId);
// Independent of the Tickets tab's own query (mirrors desktop SprintManager):
// used to resolve which refs already belong to a sprint, both for the
// compact per-sprint ticket list and the "ajouter des tickets" exclude set.
const search = useTicketSearch(projectId, { refreshOnEvents: true });
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState("");
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string } | null>(null);
const [pickerSprint, setPickerSprint] = useState<{ id: string; name: string } | null>(null);
const sprints = [...vm.sprints].sort((a, b) => a.order - b.order);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const name = newName.trim();
if (!name) return;
const created = await vm.createSprint(name);
if (created) {
setNewName("");
setShowCreate(false);
}
}
return (
<Panel
title="Sprints"
actions={
<Button size="sm" onClick={() => setShowCreate((v) => !v)}>
+ Sprint
</Button>
}
>
{vm.error && (
<p role="alert" className="mb-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
{vm.error}
</p>
)}
{showCreate && (
<form onSubmit={handleCreate} className="mb-3 flex flex-col gap-2 rounded-md border border-border bg-raised/50 p-2">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Nom du sprint</span>
<Input
aria-label="Nom du sprint"
value={newName}
onChange={(e) => setNewName(e.target.value)}
autoFocus
/>
</label>
<div className="flex justify-end gap-2">
<Button type="button" size="sm" variant="ghost" onClick={() => setShowCreate(false)}>
Annuler
</Button>
<Button type="submit" size="sm" disabled={!newName.trim() || vm.busy}>
Créer
</Button>
</div>
</form>
)}
{vm.busy && vm.sprints.length === 0 ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
<Spinner size={12} /> Chargement des sprints
</span>
) : sprints.length === 0 ? (
<div className="flex flex-col items-start gap-2">
<p className="text-sm text-muted">Aucun sprint.</p>
<Button size="sm" onClick={() => setShowCreate(true)}>
Créer un sprint
</Button>
</div>
) : (
<ul className="flex flex-col gap-3">
{sprints.map((sprint, index) => (
<SprintCard
key={sprint.id}
sprint={sprint}
index={index}
lastIndex={sprints.length - 1}
tickets={search.rows.filter((t) => t.sprintId === sprint.id)}
vm={vm}
renaming={renamingId === sprint.id}
renameDraft={renameDraft}
onStartRename={() => {
setRenamingId(sprint.id);
setRenameDraft(sprint.name);
}}
onRenameDraftChange={setRenameDraft}
onCancelRename={() => setRenamingId(null)}
onSaveRename={async () => {
const ok = await vm.renameSprint(sprint.id, renameDraft.trim());
if (ok) setRenamingId(null);
}}
onViewTickets={() => onViewSprintTickets(sprint.id)}
onAddTickets={() => setPickerSprint({ id: sprint.id, name: sprint.name })}
onRequestDelete={() => setConfirmDelete({ id: sprint.id, name: sprint.name })}
/>
))}
</ul>
)}
{confirmDelete && (
<WebConfirmDialog
title={`Supprimer le sprint « ${confirmDelete.name} » ?`}
body="Les tickets de ce sprint seront conservés et passeront en « Sans sprint »."
confirmLabel="Supprimer"
busy={vm.busy}
onCancel={() => setConfirmDelete(null)}
onConfirm={async () => {
await vm.deleteSprint(confirmDelete.id);
setConfirmDelete(null);
}}
/>
)}
{pickerSprint && (
<WebTicketPickerSheet
projectId={projectId}
title={`Ajouter des tickets au sprint « ${pickerSprint.name} »`}
confirmLabel="Ajouter au sprint"
selectionMode="multi"
excludeRefs={search.rows.filter((t) => t.sprintId === pickerSprint.id).map((t) => t.ref)}
onSelect={(result) => {
const picked = Array.isArray(result) ? result : [result];
for (const p of picked) void vm.assignSprint(p.ref, pickerSprint.id);
}}
onClose={() => setPickerSprint(null)}
/>
)}
</Panel>
);
}
interface SprintCardProps {
sprint: Sprint;
index: number;
lastIndex: number;
tickets: TicketSummary[];
vm: TicketsViewModel;
renaming: boolean;
renameDraft: string;
onStartRename: () => void;
onRenameDraftChange: (value: string) => void;
onCancelRename: () => void;
onSaveRename: () => Promise<void>;
onViewTickets: () => void;
onAddTickets: () => void;
onRequestDelete: () => void;
}
function SprintCard({
sprint,
index,
lastIndex,
tickets,
vm,
renaming,
renameDraft,
onStartRename,
onRenameDraftChange,
onCancelRename,
onSaveRename,
onViewTickets,
onAddTickets,
onRequestDelete,
}: SprintCardProps) {
return (
<li className="rounded-md border border-border p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-muted">#{sprint.order}</span>
{!renaming && (
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
{sprint.name}
</span>
)}
</div>
{renaming ? (
<div className="mt-2 flex flex-col gap-2">
<Input
aria-label={`Renommer le sprint ${sprint.name}`}
value={renameDraft}
onChange={(e) => onRenameDraftChange(e.target.value)}
disabled={vm.busy}
/>
<div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={onCancelRename}>
Annuler
</Button>
<Button
size="sm"
disabled={vm.busy || !renameDraft.trim() || renameDraft.trim() === sprint.name}
onClick={() => void onSaveRename()}
>
Enregistrer
</Button>
</div>
</div>
) : (
<>
<p className="mt-1 text-xs text-muted">
{sprint.ticketCount} ticket{sprint.ticketCount > 1 ? "s" : ""}
</p>
{/* Compact ticket list + per-row removal (QA #86 fix): symmetric with
"Ajouter tickets" below. */}
{tickets.length > 0 && (
<ul className="mt-2 flex flex-col gap-1">
{tickets.map((t) => (
<li key={t.ref} className="flex items-center gap-2 text-sm">
<code className="shrink-0 rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-content">
{t.ref}
</code>
<span className="min-w-0 flex-1 truncate text-content">{t.title}</span>
<Button
size="sm"
variant="ghost"
aria-label={`Retirer ${t.ref} du sprint ${sprint.name}`}
disabled={vm.busy}
onClick={() => void vm.assignSprint(t.ref, null)}
>
Retirer du sprint
</Button>
</li>
))}
</ul>
)}
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button size="sm" variant="ghost" onClick={onViewTickets}>
Voir tickets
</Button>
<Button size="sm" variant="ghost" onClick={onAddTickets}>
Ajouter tickets
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Renommer ${sprint.name}`}
disabled={vm.busy}
onClick={onStartRename}
>
Renommer
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Monter ${sprint.name}`}
disabled={vm.busy || index === 0}
onClick={() => void vm.moveSprint(sprint.id, "up")}
>
Monter
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Descendre ${sprint.name}`}
disabled={vm.busy || index === lastIndex}
onClick={() => void vm.moveSprint(sprint.id, "down")}
>
Descendre
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Supprimer ${sprint.name}`}
disabled={vm.busy}
className="text-danger hover:text-danger"
onClick={onRequestDelete}
>
Supprimer
</Button>
</div>
</>
)}
</li>
);
}

View File

@ -0,0 +1,141 @@
/**
* Ticket creation screen for the web workspace (ticket #86) — replaces the list
* in the column (no inline permanent form, no desktop popup), per carnet #86.
*
* Short form: Titre (required) / Priorité / Sprint / Description (optional).
* On success the caller opens the created ticket's detail so the user can
* complete carnet/assignments/links right away.
*/
import { useState } from "react";
import type { CreateTicketInput } from "@/ports";
import type { Sprint, TicketPriority } from "@/domain";
import { Button, Input, Panel, cn } from "@/shared";
import { TICKET_PRIORITIES } from "@/features/tickets";
import { webPriorityLabel } from "./webTicketLabels";
const selectClass = cn(
"h-9 rounded-md bg-raised px-3 text-sm text-content",
"border border-border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
);
const textareaClass = cn(
"w-full rounded-md bg-raised p-3 text-sm text-content",
"border border-border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
);
export interface WebTicketCreateProps {
sprints: Sprint[];
busy: boolean;
error: string | null;
onCancel: () => void;
onCreate: (input: CreateTicketInput, sprintId: string | null) => Promise<void>;
}
export function WebTicketCreate({ sprints, busy, error, onCancel, onCreate }: WebTicketCreateProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState<TicketPriority>("medium");
const [sprintId, setSprintId] = useState<string>("");
async function submit(e: React.FormEvent) {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) return;
await onCreate(
{
title: trimmed,
priority,
...(description.trim() ? { description: description.trim() } : {}),
},
sprintId || null,
);
}
return (
<Panel
title="Nouveau ticket"
actions={
<Button size="sm" variant="ghost" onClick={onCancel}>
Annuler
</Button>
}
>
{error && (
<p role="alert" className="mb-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
{error}
</p>
)}
<form onSubmit={submit} className="flex flex-col gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Titre</span>
<Input
aria-label="Titre"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={busy}
autoFocus
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Priorité</span>
<select
aria-label="Priorité"
className={selectClass}
value={priority}
disabled={busy}
onChange={(e) => setPriority(e.target.value as TicketPriority)}
>
{TICKET_PRIORITIES.map((p) => (
<option key={p} value={p}>
{webPriorityLabel(p)}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Sprint</span>
<select
aria-label="Sprint"
className={selectClass}
value={sprintId}
disabled={busy}
onChange={(e) => setSprintId(e.target.value)}
>
<option value="">Sans sprint</option>
{sprints.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Description</span>
<textarea
aria-label="Description"
className={cn(textareaClass, "min-h-20")}
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={busy}
/>
</label>
<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="ghost" size="sm" disabled={busy} onClick={onCancel}>
Annuler
</Button>
<Button type="submit" size="sm" disabled={!title.trim() || busy} loading={busy}>
Créer
</Button>
</div>
</form>
</Panel>
);
}

View File

@ -0,0 +1,460 @@
/**
* Ticket detail/edit view for the web workspace (ticket #86) — replaces the
* list in the column (no floating window), with an explicit back button.
* Sections are stacked accordions: `Résumé`, `Statut et priorité` and `Carnet`
* open by default; `Agents assignés`, `Liens` and `Zone dangereuse` collapsed,
* per carnet #86.
*
* Reuses `useTicketDetail` as-is (transport-neutral); only presentation and
* copy are web-specific, including the conflict message, which this surface
* renders in French independently of the hook's own (English, desktop-shared)
* `error` string — the desktop `TicketDetail` does the same thing (its own
* hardcoded copy keyed off `vm.conflict`, not `vm.error`).
*/
import { useEffect, useRef, useState } from "react";
import type { Sprint, TicketLinkKind, TicketPriority, TicketStatus } from "@/domain";
import { TICKET_LINK_KINDS, TICKET_PRIORITIES, TICKET_STATUSES, useTicketDetail } from "@/features/tickets";
import { Button, Panel, Spinner, cn } from "@/shared";
import type { WebProjectAgent } from "./useWebProjectAgents";
import { WebConfirmDialog } from "./WebConfirmDialog";
import { WebTicketPickerSheet } from "./WebTicketPickerSheet";
import { WebPriorityBadge, WebStatusBadge, webLinkKindLabel, webPriorityLabel, webStatusLabel } from "./webTicketLabels";
const selectClass = cn(
"h-9 rounded-md bg-raised px-3 text-sm text-content",
"border border-border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
);
const textareaClass = cn(
"w-full rounded-md bg-raised p-3 text-sm text-content",
"border border-border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
);
export interface WebTicketDetailProps {
projectId: string;
ticketRef: string;
nameOf: (agentId: string) => string;
assignableAgents: WebProjectAgent[];
sprints: Sprint[];
onBack: () => void;
onOpenRef: (ref: string) => void;
}
function Accordion({
title,
defaultOpen,
children,
}: {
title: string;
defaultOpen: boolean;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<section className="border-b border-border py-2 last:border-b-0">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex min-h-[32px] w-full items-center justify-between text-left text-xs font-semibold uppercase tracking-wide text-muted"
>
{title}
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
</button>
{open && <div className="mt-2 flex flex-col gap-2">{children}</div>}
</section>
);
}
export function WebTicketDetail({
projectId,
ticketRef,
nameOf,
assignableAgents,
sprints,
onBack,
onOpenRef,
}: WebTicketDetailProps) {
const vm = useTicketDetail(projectId, ticketRef);
const t = vm.ticket;
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [carnet, setCarnet] = useState("");
const [linkKind, setLinkKind] = useState<TicketLinkKind>("relatesTo");
const [assignPick, setAssignPick] = useState("");
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmBack, setConfirmBack] = useState(false);
const [showLinkPicker, setShowLinkPicker] = useState(false);
useEffect(() => {
if (vm.deleted) onBack();
}, [vm.deleted, onBack]);
// Re-seed the draft only on a genuine (re)load — mirrors the desktop
// `TicketDetail` (#9): an ordinary optimistic mutation (status/priority)
// must never clobber an in-progress title/description/carnet edit.
const reloadCount = vm.reloadCount;
const seededReload = useRef(-1);
const baseline = useRef<{ title: string; description: string; carnet: string } | null>(null);
useEffect(() => {
if (!t) return;
if (seededReload.current === reloadCount) return;
seededReload.current = reloadCount;
const nextCarnet = t.carnet ?? "";
const base = baseline.current;
const forced = base === null || vm.conflict;
if (forced) {
setTitle(t.title);
setDescription(t.description);
setCarnet(nextCarnet);
} else {
setTitle((cur) => (cur === base.title ? t.title : cur));
setDescription((cur) => (cur === base.description ? t.description : cur));
setCarnet((cur) => (cur === base.carnet ? nextCarnet : cur));
}
baseline.current = { title: t.title, description: t.description, carnet: nextCarnet };
}, [t, reloadCount, vm.conflict]);
const dirtyFields = !!t && (title !== t.title || description !== t.description);
const dirtyCarnet = !!t && carnet !== (t.carnet ?? "");
const dirty = dirtyFields || dirtyCarnet;
const assignable = assignableAgents.filter((a) => !t?.assignedAgentIds.includes(a.id));
function requestBack() {
if (dirty) setConfirmBack(true);
else onBack();
}
async function saveAndBack() {
let ok = true;
if (dirtyFields) ok = (await vm.updateFields({ title, description })) && ok;
if (ok && dirtyCarnet) ok = (await vm.saveCarnet(carnet)) && ok;
setConfirmBack(false);
if (ok) onBack();
}
return (
<Panel className="flex flex-col" flush>
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-3">
<Button size="sm" variant="ghost" onClick={requestBack}>
Tickets
</Button>
<code className="rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-content">{ticketRef}</code>
</div>
{vm.conflict && (
<p role="alert" className="border-b border-warning/40 bg-warning/10 px-4 py-2 text-sm text-warning">
Ce ticket a é modifié ailleurs et rechargé. Réappliquez votre modification.
</p>
)}
{vm.error && !vm.conflict && (
<p role="alert" className="border-b border-danger/40 bg-danger/10 px-4 py-2 text-sm text-danger">
{vm.error}
</p>
)}
{vm.busy && !t ? (
<div className="flex items-center gap-2 px-4 py-5 text-sm text-muted">
<Spinner size={14} />
<span>Chargement du ticket</span>
</div>
) : !t ? (
<p className="px-4 py-5 text-sm text-muted">Ticket introuvable.</p>
) : (
<div className="flex flex-col px-4 py-2">
<div className="flex flex-col gap-1 border-b border-border pb-3">
<span className="text-sm font-semibold text-content">{t.title}</span>
<span className="flex flex-wrap items-center gap-1.5 text-xs text-muted">
<WebStatusBadge status={t.status} />
<WebPriorityBadge priority={t.priority} />
{t.sprintId && <span>· {sprints.find((s) => s.id === t.sprintId)?.name ?? "Sprint"}</span>}
</span>
</div>
<Accordion title="Résumé" defaultOpen>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Titre</span>
<input
aria-label="Titre"
className={cn(selectClass, "w-full")}
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={vm.busy}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Description</span>
<textarea
aria-label="Description"
className={cn(textareaClass, "min-h-24")}
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={vm.busy}
/>
</label>
<div>
<Button
size="sm"
disabled={!dirtyFields || vm.busy}
loading={vm.busy && dirtyFields}
onClick={() => void vm.updateFields({ title, description })}
>
Enregistrer
</Button>
</div>
</Accordion>
<Accordion title="Statut et priorité" defaultOpen>
<label className="flex items-center justify-between gap-2 text-sm">
<span className="text-muted">Statut</span>
<select
aria-label="Statut"
className={selectClass}
value={t.status}
disabled={vm.busy}
onChange={(e) => void vm.updateFields({ status: e.target.value as TicketStatus })}
>
{TICKET_STATUSES.map((s) => (
<option key={s} value={s}>
{webStatusLabel(s)}
</option>
))}
</select>
</label>
<label className="flex items-center justify-between gap-2 text-sm">
<span className="text-muted">Priorité</span>
<select
aria-label="Priorité"
className={selectClass}
value={t.priority}
disabled={vm.busy}
onChange={(e) => void vm.updateFields({ priority: e.target.value as TicketPriority })}
>
{TICKET_PRIORITIES.map((p) => (
<option key={p} value={p}>
{webPriorityLabel(p)}
</option>
))}
</select>
</label>
<label className="flex items-center justify-between gap-2 text-sm">
<span className="text-muted">Sprint</span>
<select
aria-label="Sprint"
className={selectClass}
value={t.sprintId ?? ""}
disabled={vm.busy}
onChange={(e) => void vm.setSprint(e.target.value || null)}
>
<option value="">Sans sprint</option>
{sprints.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</label>
{vm.busy && <Spinner size={12} />}
</Accordion>
<Accordion
title="Carnet"
defaultOpen
>
<textarea
aria-label="Carnet"
className={cn(textareaClass, "min-h-32 font-mono")}
value={carnet}
onChange={(e) => setCarnet(e.target.value)}
disabled={vm.busy}
/>
<div>
<Button
size="sm"
disabled={!dirtyCarnet || vm.busy}
loading={vm.busy && dirtyCarnet}
onClick={() => void vm.saveCarnet(carnet)}
>
Enregistrer le carnet
</Button>
</div>
</Accordion>
<Accordion title="Agents assignés" defaultOpen={false}>
{t.assignedAgentIds.length === 0 ? (
<p className="text-xs text-muted">Aucun agent assigné.</p>
) : (
<ul className="flex flex-wrap gap-2">
{t.assignedAgentIds.map((id) => (
<li key={id} className="flex items-center gap-1 rounded-full bg-raised px-2 py-0.5 text-xs text-content">
{nameOf(id)}
<button
type="button"
aria-label={`Désassigner ${nameOf(id)}`}
className="text-muted hover:text-danger disabled:opacity-50"
disabled={vm.busy}
onClick={() => void vm.assign(id, false)}
>
×
</button>
</li>
))}
</ul>
)}
<div className="flex items-center gap-2">
<select
aria-label="Assigner un agent"
className={selectClass}
value={assignPick}
disabled={vm.busy || assignable.length === 0}
onChange={(e) => setAssignPick(e.target.value)}
>
<option value="">
{assignable.length === 0 ? "Aucun autre agent" : "Choisir un agent…"}
</option>
{assignable.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
<Button
size="sm"
disabled={!assignPick || vm.busy}
onClick={async () => {
const ok = await vm.assign(assignPick, true);
if (ok) setAssignPick("");
}}
>
Assigner
</Button>
</div>
</Accordion>
<Accordion title="Liens" defaultOpen={false}>
{t.links.length === 0 ? (
<p className="text-xs text-muted">Aucun ticket lié.</p>
) : (
<ul className="flex flex-col gap-1">
{t.links.map((l) => (
<li key={`${l.kind}-${l.targetRef}`} className="flex items-center gap-2 text-sm">
<span className="text-xs text-muted">{webLinkKindLabel(l.kind)}</span>
<button
type="button"
className="rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-content hover:text-primary"
onClick={() => onOpenRef(l.targetRef)}
>
{l.targetRef}
</button>
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() => void vm.unlink(l.targetRef, l.kind)}
>
Retirer
</Button>
</li>
))}
</ul>
)}
<div className="flex flex-wrap items-center gap-2">
<select
aria-label="Type de lien"
className={selectClass}
value={linkKind}
disabled={vm.busy}
onChange={(e) => setLinkKind(e.target.value as TicketLinkKind)}
>
{TICKET_LINK_KINDS.map((k) => (
<option key={k} value={k}>
{webLinkKindLabel(k)}
</option>
))}
</select>
<Button size="sm" disabled={vm.busy} onClick={() => setShowLinkPicker(true)}>
+ Lier
</Button>
</div>
</Accordion>
<Accordion title="Zone dangereuse" defaultOpen={false}>
<p className="text-xs text-muted">
Le ticket {ticketRef} sera supprimé définitivement. Cette action ne supprime pas les
sprints ni les agents assignés.
</p>
<div>
<Button
size="sm"
variant="danger"
disabled={vm.busy}
onClick={() => setConfirmDelete(true)}
>
Supprimer
</Button>
</div>
</Accordion>
</div>
)}
{showLinkPicker && t && (
<WebTicketPickerSheet
projectId={projectId}
title="Lier un ticket"
selectionMode="single"
excludeRefs={[ticketRef, ...t.links.map((l) => l.targetRef)]}
onSelect={(result) => {
const picked = Array.isArray(result) ? result[0] : result;
if (picked) void vm.link(picked.ref, linkKind);
}}
onClose={() => setShowLinkPicker(false)}
/>
)}
{confirmDelete && (
<WebConfirmDialog
title="Supprimer ce ticket ?"
body={`Le ticket ${ticketRef} sera supprimé définitivement. Cette action ne supprime pas les sprints ni les agents assignés.`}
confirmLabel="Supprimer"
busy={vm.busy}
onCancel={() => setConfirmDelete(false)}
onConfirm={async () => {
const ok = await vm.remove();
if (ok) setConfirmDelete(false);
}}
/>
)}
{confirmBack && (
<div
role="alertdialog"
aria-modal="true"
aria-label="Modifications non enregistrées"
className="fixed inset-0 flex items-center justify-center bg-black/50 p-4"
>
<div className="flex w-full max-w-sm flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl">
<h3 className="text-sm font-semibold text-content">
Des modifications ne sont pas enregistrées.
</h3>
<div className="flex flex-wrap justify-end gap-2">
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={() => setConfirmBack(false)}>
Continuer l'édition
</Button>
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={onBack}>
Ignorer
</Button>
<Button size="sm" loading={vm.busy} disabled={vm.busy} onClick={() => void saveAndBack()}>
Enregistrer et quitter
</Button>
</div>
</div>
</div>
)}
</Panel>
);
}

View File

@ -0,0 +1,207 @@
/**
* Mobile-adapted ticket picker for the web workspace (ticket #86): a full-screen
* panel — never a small centered desktop modal — with a fixed header, search at
* the top, a scrollable result list, and actions pinned at the bottom. Reused
* for both "ajouter des tickets à un sprint" (multi-select) and "lier un
* ticket" (single-select), same as the desktop `TicketPicker` reuses across
* those two call sites.
*
* Business logic (search/pagination) comes straight from the transport-neutral
* `useTicketSearch` hook; only the chrome is web-specific.
*/
import { useEffect, useRef } from "react";
import { useState } from "react";
import type { TicketRef } from "@/domain";
import { useTicketSearch, type TicketPickerResult } from "@/features/tickets";
import { Button, Input, Spinner, cn, zIndex } from "@/shared";
import { WebPriorityBadge, WebStatusBadge } from "./webTicketLabels";
export type WebTicketPickerSelectionMode = "single" | "multi";
export interface WebTicketPickerSheetProps {
projectId: string;
title: string;
/** Multi-select only; the count is appended automatically. */
confirmLabel?: string;
selectionMode: WebTicketPickerSelectionMode;
excludeRefs?: TicketRef[];
onSelect: (result: TicketPickerResult | TicketPickerResult[]) => void;
onClose: () => void;
}
export function WebTicketPickerSheet({
projectId,
title,
confirmLabel = "Ajouter",
selectionMode,
excludeRefs,
onSelect,
onClose,
}: WebTicketPickerSheetProps) {
const vm = useTicketSearch(projectId, { excludeRefs });
const isMulti = selectionMode === "multi";
const [selected, setSelected] = useState<Set<TicketRef>>(() => new Set());
const [busy, setBusy] = useState(false);
const [resolveError, setResolveError] = useState<string | null>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
closeRef.current?.focus();
}, []);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
function describeErr(e: unknown): string {
return e && typeof e === "object" && "message" in e
? String((e as { message: unknown }).message)
: String(e);
}
function toggle(ref: TicketRef) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(ref)) next.delete(ref);
else next.add(ref);
return next;
});
}
async function pickSingle(ref: TicketRef) {
if (busy) return;
setBusy(true);
setResolveError(null);
try {
onSelect(await vm.resolve(ref));
onClose();
} catch (e) {
setResolveError(describeErr(e));
setBusy(false);
}
}
async function confirmMulti() {
if (busy || selected.size === 0) return;
setBusy(true);
setResolveError(null);
try {
const results = await Promise.all(Array.from(selected).map((ref) => vm.resolve(ref)));
onSelect(results);
onClose();
} catch (e) {
setResolveError(describeErr(e));
setBusy(false);
}
}
return (
<div
className="fixed inset-0 flex flex-col bg-canvas"
role="dialog"
aria-modal="true"
aria-label={title}
style={{ zIndex: zIndex.floatingWindowNested }}
>
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3 pt-[max(0.75rem,env(safe-area-inset-top))]">
<span className="min-w-0 truncate text-sm font-medium text-content">{title}</span>
<Button ref={closeRef} size="sm" variant="ghost" onClick={onClose}>
Fermer
</Button>
</header>
<div className="shrink-0 border-b border-border px-4 py-3">
<Input
aria-label="Rechercher des tickets"
placeholder="Recherche…"
value={vm.text}
onChange={(e) => vm.setText(e.target.value)}
autoFocus
/>
</div>
{(vm.error || resolveError) && (
<p role="alert" className="shrink-0 border-b border-danger/40 bg-danger/10 px-4 py-2 text-sm text-danger">
{resolveError ?? vm.error}
</p>
)}
<div className="min-h-0 flex-1 overflow-auto px-4 py-3">
{vm.busy && vm.rows.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>Chargement des tickets</span>
</div>
) : vm.rows.length === 0 ? (
<p className="text-sm text-muted">Aucun ticket ne correspond.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{vm.rows.map((t) => {
const checked = selected.has(t.ref);
return (
<li key={t.ref} className="py-1.5 first:pt-0 last:pb-0">
<button
type="button"
aria-label={`${t.ref}, ${t.title}`}
{...(isMulti ? { role: "checkbox", "aria-checked": checked } : {})}
disabled={busy}
onClick={() => (isMulti ? toggle(t.ref) : void pickSingle(t.ref))}
className={cn(
"flex min-h-[40px] w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
"hover:bg-raised focus:bg-raised focus:outline-none disabled:opacity-60",
isMulti && checked && "bg-raised",
)}
>
{isMulti && (
<span
aria-hidden="true"
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded border text-[10px] font-bold",
checked ? "border-primary bg-primary text-on-primary" : "border-border text-transparent",
)}
>
</span>
)}
<code className="shrink-0 rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-content">
{t.ref}
</code>
<span className="min-w-0 flex-1 truncate text-sm text-content">{t.title}</span>
<span className="flex shrink-0 items-center gap-1">
<WebStatusBadge status={t.status} />
<WebPriorityBadge priority={t.priority} />
</span>
</button>
</li>
);
})}
</ul>
)}
{vm.hasMore && (
<div className="mt-3 flex justify-center">
<Button size="sm" variant="ghost" loading={vm.busy} onClick={() => vm.loadMore()}>
Charger plus
</Button>
</div>
)}
</div>
{isMulti && (
<footer className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
<span className="text-xs text-muted" aria-live="polite">
{selected.size} sélectionné{selected.size > 1 ? "s" : ""}
</span>
<Button size="sm" loading={busy} disabled={selected.size === 0 || busy} onClick={() => void confirmMulti()}>
{confirmLabel} {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</footer>
)}
</div>
);
}

View File

@ -0,0 +1,448 @@
/**
* Ticket #86, lot 2 — the web workspace's Tickets/Sprints tabs.
*
* Pins the carnet #86 UX contract exercised through the real `WebWorkspace`
* (project list → tab navigation), driven entirely by the same
* `MockTicketGateway`/`MockSystemGateway` pair the desktop ticket tests use, so
* behaviour (event-driven refresh, optimistic concurrency, sprint semantics)
* stays identical — only the presentation is web-specific.
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { DIProvider } from "@/app/di";
import { createMockGateways, MockTicketGateway, MockWorkStateGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import type { ProjectWorkState, Ticket } from "@/domain";
import { WebWorkspace } from "../WebWorkspace";
function ticket(over: Partial<Ticket> = {}): Ticket {
return {
id: "t-1",
ref: "#1",
number: 1,
title: "Ajouter tickets/sprints au web",
description: "",
status: "open",
priority: "medium",
sprintId: null,
links: [],
assignedAgentIds: [],
createdBy: { kind: "user" },
updatedBy: { kind: "user" },
createdAt: 1,
updatedAt: 1,
version: 1,
...over,
};
}
async function setup() {
const gateways = createMockGateways();
const project = await gateways.project.createProject("IdeA", "/srv/idea");
return { gateways, projectId: project.id };
}
/**
* Seeds a project's work-state with one idle agent. `WebTicketDetail`/
* `WebTicketsView` resolve the assignable agent roster from
* `get_project_work_state` (not `list_agents` — absent from the web-server
* command allowlist, see `useWebProjectAgents`), so tests that need an
* assignable agent must seed it here rather than via `MockAgentGateway`.
*/
function seedAgent(gateways: Gateways, projectId: string, agentId: string, name: string): void {
const state: ProjectWorkState = {
agents: [
{
agentId,
name,
profileId: "p1",
busy: { state: "idle" },
tickets: [],
},
],
conversations: [],
};
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(projectId, state);
}
function renderWorkspace(gateways: Gateways) {
return render(
<DIProvider gateways={gateways}>
<WebWorkspace />
</DIProvider>,
);
}
async function openProjectAndTab(gateways: Gateways, tabName: string) {
renderWorkspace(gateways);
fireEvent.click(await screen.findByText("IdeA"));
await screen.findByRole("tablist", { name: "Navigation du projet" });
fireEvent.click(screen.getByRole("tab", { name: tabName }));
}
describe("WebWorkspace — project tabs (ticket #86)", () => {
it("defaults to the Live tab and lets the user switch to Tickets/Sprints", async () => {
const { gateways } = await setup();
renderWorkspace(gateways);
fireEvent.click(await screen.findByText("IdeA"));
const tablist = await screen.findByRole("tablist", { name: "Navigation du projet" });
const tabs = within(tablist).getAllByRole("tab");
expect(tabs.map((t) => t.textContent)).toEqual(["Live", "Tickets", "Sprints"]);
expect(screen.getByRole("tab", { name: "Live" }).getAttribute("aria-selected")).toBe("true");
fireEvent.click(screen.getByRole("tab", { name: "Tickets" }));
expect(await screen.findByRole("heading", { name: "Tickets" })).toBeTruthy();
fireEvent.click(screen.getByRole("tab", { name: "Sprints" }));
expect(await screen.findByRole("heading", { name: "Sprints" })).toBeTruthy();
});
it("renders the ticket list grouped by sprint, in French", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint courant" });
tk._seedTicket(projectId, ticket({ ref: "#1", number: 1, title: "Ajouter tickets/sprints au web", sprintId: "s1" }));
tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Langue uniforme Settings", status: "closed", priority: "low" }));
await openProjectAndTab(gateways, "Tickets");
const list = await screen.findByTestId("web-ticket-list");
expect(within(list).getByText("Sprint courant")).toBeTruthy();
expect(within(list).getByText("Sans sprint")).toBeTruthy();
expect(
screen.getByRole("button", {
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
}),
).toBeTruthy();
expect(
screen.getByRole("button", {
name: "#2, Langue uniforme Settings, statut Fermé, priorité Faible",
}),
).toBeTruthy();
});
it("shows the empty state with a create shortcut when the project has no tickets", async () => {
const { gateways } = await setup();
await openProjectAndTab(gateways, "Tickets");
expect(await screen.findByText("Aucun ticket dans ce projet.")).toBeTruthy();
expect(screen.getByRole("button", { name: "Créer un ticket" })).toBeTruthy();
});
it("creates a ticket and opens its detail", async () => {
const { gateways } = await setup();
await openProjectAndTab(gateways, "Tickets");
await screen.findByText("Aucun ticket dans ce projet.");
fireEvent.click(screen.getByRole("button", { name: "+ Ticket" }));
expect(await screen.findByRole("heading", { name: "Nouveau ticket" })).toBeTruthy();
fireEvent.change(screen.getByLabelText("Titre"), { target: { value: "Un nouveau ticket" } });
fireEvent.click(screen.getByRole("button", { name: "Créer" }));
await waitFor(() => expect(screen.getByText("Un nouveau ticket")).toBeTruthy());
// Landed on the detail screen (back button visible), not the list.
expect(screen.getByRole("button", { name: "← Tickets" })).toBeTruthy();
});
it("changes a ticket's status immediately from the detail view", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedTicket(projectId, ticket());
await openProjectAndTab(gateways, "Tickets");
fireEvent.click(
await screen.findByRole("button", {
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
}),
);
const statusSelect = await screen.findByLabelText("Statut");
fireEvent.change(statusSelect, { target: { value: "closed" } });
await waitFor(async () => {
const updated = await tk.read(projectId, "#1");
expect(updated.status).toBe("closed");
});
});
it("changes a ticket's sprint from the detail view, including clearing it back to Sans sprint", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint courant" });
tk._seedSprint(projectId, { id: "s2", order: 2, name: "Sprint suivant" });
tk._seedTicket(projectId, ticket());
await openProjectAndTab(gateways, "Tickets");
fireEvent.click(
await screen.findByRole("button", {
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
}),
);
const sprintSelect = await screen.findByLabelText("Sprint");
fireEvent.change(sprintSelect, { target: { value: "s2" } });
await waitFor(async () => {
const updated = await tk.read(projectId, "#1");
expect(updated.sprintId).toBe("s2");
});
// Clear it back to "Sans sprint" (ticket_unassign_sprint via setSprint(null)).
fireEvent.change(await screen.findByLabelText("Sprint"), { target: { value: "" } });
await waitFor(async () => {
const updated = await tk.read(projectId, "#1");
expect(updated.sprintId).toBeNull();
});
});
it("assigns and unassigns an agent from the detail view", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedTicket(projectId, ticket());
seedAgent(gateways, projectId, "agent-1", "DevFrontend");
await openProjectAndTab(gateways, "Tickets");
fireEvent.click(
await screen.findByRole("button", {
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
}),
);
fireEvent.click(await screen.findByRole("button", { name: "Agents assignés" }));
fireEvent.change(screen.getByLabelText("Assigner un agent"), {
target: { value: "agent-1" },
});
fireEvent.click(screen.getByRole("button", { name: "Assigner" }));
await waitFor(async () => {
const updated = await tk.read(projectId, "#1");
expect(updated.assignedAgentIds).toContain("agent-1");
});
await screen.findByText("DevFrontend");
fireEvent.click(screen.getByRole("button", { name: "Désassigner DevFrontend" }));
await waitFor(async () => {
const updated = await tk.read(projectId, "#1");
expect(updated.assignedAgentIds).not.toContain("agent-1");
});
});
it("links and unlinks a ticket from the detail view", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedTicket(projectId, ticket());
tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Autre ticket" }));
await openProjectAndTab(gateways, "Tickets");
fireEvent.click(
await screen.findByRole("button", {
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
}),
);
fireEvent.click(await screen.findByRole("button", { name: "Liens" }));
fireEvent.click(screen.getByRole("button", { name: "+ Lier" }));
const sheet = await screen.findByRole("dialog", { name: "Lier un ticket" });
fireEvent.click(within(sheet).getByRole("button", { name: "#2, Autre ticket" }));
await waitFor(async () => {
const updated = await tk.read(projectId, "#1");
expect(updated.links).toEqual([{ targetRef: "#2", kind: "relatesTo" }]);
});
await screen.findByText("#2");
fireEvent.click(screen.getByRole("button", { name: "Retirer" }));
await waitFor(async () => {
const updated = await tk.read(projectId, "#1");
expect(updated.links).toEqual([]);
});
});
it("saves the carnet explicitly from the detail view", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedTicket(projectId, ticket());
await openProjectAndTab(gateways, "Tickets");
fireEvent.click(
await screen.findByRole("button", {
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
}),
);
const carnetField = await screen.findByLabelText("Carnet");
const saveButton = screen.getByRole("button", { name: "Enregistrer le carnet" });
expect(saveButton).toHaveProperty("disabled", true);
fireEvent.change(carnetField, { target: { value: "Notes de travail" } });
expect(saveButton).toHaveProperty("disabled", false);
fireEvent.click(saveButton);
await waitFor(async () => {
const updated = await tk.readCarnet(projectId, "#1");
expect(updated.carnet).toBe("Notes de travail");
});
});
it("filters the ticket list by free-text search", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedTicket(projectId, ticket({ ref: "#1", number: 1, title: "Ajouter tickets/sprints au web" }));
tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Langue uniforme Settings" }));
await openProjectAndTab(gateways, "Tickets");
await screen.findByText("Langue uniforme Settings");
fireEvent.change(screen.getByLabelText("Rechercher des tickets"), {
target: { value: "Langue" },
});
await waitFor(() => {
expect(screen.queryByText("Ajouter tickets/sprints au web")).toBeNull();
});
expect(screen.getByText("Langue uniforme Settings")).toBeTruthy();
});
it("deletes a ticket after confirmation and returns to the list", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedTicket(projectId, ticket());
await openProjectAndTab(gateways, "Tickets");
fireEvent.click(
await screen.findByRole("button", {
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
}),
);
fireEvent.click(await screen.findByRole("button", { name: "Zone dangereuse" }));
fireEvent.click(screen.getByRole("button", { name: "Supprimer" }));
const dialog = await screen.findByRole("alertdialog", { name: "Supprimer ce ticket ?" });
expect(within(dialog).getByText(/sera supprimé définitivement/)).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Supprimer" }));
await waitFor(() => expect(screen.getByText("Aucun ticket dans ce projet.")).toBeTruthy());
});
});
describe("WebWorkspace — Sprints tab (ticket #86)", () => {
it("shows the empty state with a create shortcut", async () => {
const { gateways } = await setup();
await openProjectAndTab(gateways, "Sprints");
expect(await screen.findByText("Aucun sprint.")).toBeTruthy();
expect(screen.getByRole("button", { name: "Créer un sprint" })).toBeTruthy();
});
it("creates a sprint (disabled until a name is entered)", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
await openProjectAndTab(gateways, "Sprints");
fireEvent.click(screen.getByRole("button", { name: "+ Sprint" }));
const createButton = screen.getByRole("button", { name: "Créer" });
expect(createButton).toHaveProperty("disabled", true);
fireEvent.change(screen.getByLabelText("Nom du sprint"), {
target: { value: "Backlog client web" },
});
expect(createButton).toHaveProperty("disabled", false);
fireEvent.click(createButton);
await waitFor(async () => {
const sprints = await tk.listSprints(projectId);
expect(sprints.map((s) => s.name)).toContain("Backlog client web");
});
expect(await screen.findByText("Backlog client web")).toBeTruthy();
});
it("renames, reorders and deletes a sprint with confirmation", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint A" });
tk._seedSprint(projectId, { id: "s2", order: 2, name: "Sprint B" });
await openProjectAndTab(gateways, "Sprints");
await screen.findByText("Sprint A");
// Rename.
fireEvent.click(screen.getByRole("button", { name: "Renommer Sprint A" }));
fireEvent.change(screen.getByLabelText("Renommer le sprint Sprint A"), {
target: { value: "Sprint A renommé" },
});
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
await waitFor(() => expect(screen.getByText("Sprint A renommé")).toBeTruthy());
// Reorder: move Sprint B up.
fireEvent.click(screen.getByRole("button", { name: "Monter Sprint B" }));
await waitFor(async () => {
const sprints = await tk.listSprints(projectId);
expect(sprints.find((s) => s.id === "s2")?.order).toBe(1);
});
// Delete with confirmation.
fireEvent.click(screen.getByRole("button", { name: "Supprimer Sprint B" }));
const dialog = await screen.findByRole("alertdialog", {
name: "Supprimer le sprint « Sprint B » ?",
});
expect(within(dialog).getByText(/passeront en « Sans sprint »/)).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Supprimer" }));
await waitFor(async () => {
const sprints = await tk.listSprints(projectId);
expect(sprints.map((s) => s.id)).not.toContain("s2");
});
});
it("adds tickets to a sprint via the mobile picker", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint A" });
tk._seedTicket(projectId, ticket({ ref: "#1", number: 1, title: "Ticket un" }));
tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Ticket deux" }));
await openProjectAndTab(gateways, "Sprints");
await screen.findByText("Sprint A");
fireEvent.click(screen.getByRole("button", { name: "Ajouter tickets" }));
const sheet = await screen.findByRole("dialog", {
name: "Ajouter des tickets au sprint « Sprint A »",
});
fireEvent.click(within(sheet).getByRole("checkbox", { name: "#1, Ticket un" }));
fireEvent.click(
within(sheet).getByRole("button", { name: /Ajouter au sprint/ }),
);
await waitFor(async () => {
const t = await tk.read(projectId, "#1");
expect(t.sprintId).toBe("s1");
});
});
it("shows the sprint's tickets and removes one from the sprint", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint A" });
tk._seedTicket(projectId, ticket({ ref: "#1", number: 1, title: "Ticket un", sprintId: "s1" }));
await openProjectAndTab(gateways, "Sprints");
await screen.findByText("Sprint A");
expect(screen.getByText("Ticket un")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Retirer #1 du sprint Sprint A" }));
await waitFor(async () => {
const t = await tk.read(projectId, "#1");
expect(t.sprintId).toBeNull();
});
await waitFor(() => expect(screen.queryByText("Ticket un")).toBeNull());
});
});

View File

@ -0,0 +1,333 @@
/**
* "Tickets" tab of the web workspace (ticket #86).
*
* A mobile-first adaptation of the desktop `TicketsPanel`/`TicketDetail` pair:
* one vertical column that swaps between **list**, **create**, and **detail**
* modes in place — never a floating window / dock, per carnet #86. Reuses the
* transport-neutral `useTickets` view-model as-is; only the presentation (and
* the French labels — decision #78) is web-specific.
*/
import { useState } from "react";
import type { Sprint, TicketSummary } from "@/domain";
import type { TicketListSortField } from "@/ports";
import { TICKET_PRIORITIES, TICKET_STATUSES, useTickets } from "@/features/tickets";
import { Button, Input, Panel, Spinner, cn } from "@/shared";
import { WebTicketCreate } from "./WebTicketCreate";
import { WebTicketDetail } from "./WebTicketDetail";
import { useWebProjectAgents } from "./useWebProjectAgents";
import { WebStatusBadge, WebPriorityBadge, webStatusLabel, webPriorityLabel } from "./webTicketLabels";
const selectClass = cn(
"h-8 rounded-md bg-raised px-2 text-xs text-content",
"border border-border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
);
const SORT_FIELD_LABEL: Record<TicketListSortField, string> = {
number: "Numéro",
priority: "Priorité",
status: "Statut",
title: "Titre",
};
type Mode = { type: "list" } | { type: "create" } | { type: "detail"; ref: string };
export interface WebTicketsViewProps {
projectId: string;
/**
* When set, the list initially shows only this sprint's tickets (from the
* Sprints tab's "Voir tickets"), with a link back to the full list.
*/
focusSprintId?: string | null;
}
export function WebTicketsView({ projectId, focusSprintId = null }: WebTicketsViewProps) {
const vm = useTickets(projectId);
const webAgents = useWebProjectAgents(projectId);
const [mode, setMode] = useState<Mode>({ type: "list" });
const [sprintFocus, setSprintFocus] = useState<string | null>(focusSprintId);
if (mode.type === "detail") {
return (
<WebTicketDetail
key={mode.ref}
projectId={projectId}
ticketRef={mode.ref}
nameOf={webAgents.nameOf}
assignableAgents={webAgents.agents}
sprints={vm.sprints}
onBack={() => setMode({ type: "list" })}
onOpenRef={(ref) => setMode({ type: "detail", ref })}
/>
);
}
if (mode.type === "create") {
return (
<WebTicketCreate
sprints={vm.sprints}
busy={vm.busy}
error={vm.error}
onCancel={() => setMode({ type: "list" })}
onCreate={async (input, sprintId) => {
const created = await vm.create(input);
if (!created) return;
if (sprintId) await vm.assignSprint(created.ref, sprintId);
setMode({ type: "detail", ref: created.ref });
}}
/>
);
}
const items = vm.list?.items ?? [];
const hasFilters =
!!vm.query.text ||
(vm.query.statuses?.length ?? 0) > 0 ||
(vm.query.priorities?.length ?? 0) > 0 ||
!!vm.query.assignedAgentId ||
!!sprintFocus;
const sprintIds = new Set(vm.sprints.map((s) => s.id));
const visibleSprints = sprintFocus ? vm.sprints.filter((s) => s.id === sprintFocus) : vm.sprints;
const grouped = visibleSprints
.map((sprint) => ({ sprint, tickets: items.filter((t) => t.sprintId === sprint.id) }))
.filter((g) => g.tickets.length > 0);
const noSprint = sprintFocus
? []
: items.filter((t) => !t.sprintId || !sprintIds.has(t.sprintId));
function resetFilters() {
vm.setQuery({});
setSprintFocus(null);
}
return (
<Panel
className="flex flex-col"
title="Tickets"
actions={
<Button size="sm" onClick={() => setMode({ type: "create" })}>
+ Ticket
</Button>
}
>
{vm.error && (
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
<span>{vm.error}</span>
<Button size="sm" variant="ghost" onClick={() => void vm.refresh()}>
Réessayer
</Button>
</div>
)}
{sprintFocus && (
<div className="mb-3 flex items-center justify-between gap-2 rounded-md border border-border bg-raised/50 px-3 py-2 text-xs text-muted">
<span>
Filtré sur le sprint « {vm.sprints.find((s) => s.id === sprintFocus)?.name ?? sprintFocus} »
</span>
<Button size="sm" variant="ghost" onClick={() => setSprintFocus(null)}>
Voir tous les tickets
</Button>
</div>
)}
{/* ── Search + compact filters ── */}
<div className="mb-3 flex flex-col gap-2">
<Input
aria-label="Rechercher des tickets"
placeholder="Recherche…"
value={vm.query.text ?? ""}
onChange={(e) => vm.setQuery({ ...vm.query, text: e.target.value || undefined })}
/>
<div className="flex flex-wrap items-center gap-2">
<select
aria-label="Filtrer par statut"
className={selectClass}
value={vm.query.statuses?.[0] ?? ""}
onChange={(e) =>
vm.setQuery({
...vm.query,
statuses: e.target.value ? [e.target.value as (typeof TICKET_STATUSES)[number]] : undefined,
})
}
>
<option value="">Tous les statuts</option>
{TICKET_STATUSES.map((s) => (
<option key={s} value={s}>
{webStatusLabel(s)}
</option>
))}
</select>
<select
aria-label="Filtrer par priorité"
className={selectClass}
value={vm.query.priorities?.[0] ?? ""}
onChange={(e) =>
vm.setQuery({
...vm.query,
priorities: e.target.value
? [e.target.value as (typeof TICKET_PRIORITIES)[number]]
: undefined,
})
}
>
<option value="">Toutes les priorités</option>
{TICKET_PRIORITIES.map((p) => (
<option key={p} value={p}>
{webPriorityLabel(p)}
</option>
))}
</select>
<select
aria-label="Filtrer par agent assigné"
className={selectClass}
value={vm.query.assignedAgentId ?? ""}
onChange={(e) =>
vm.setQuery({ ...vm.query, assignedAgentId: e.target.value || undefined })
}
>
<option value="">Tous les agents</option>
{webAgents.agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
<select
aria-label="Trier les tickets"
className={selectClass}
value={vm.query.sort?.field ?? ""}
onChange={(e) => {
const field = e.target.value as TicketListSortField | "";
const { sort: _drop, ...rest } = vm.query;
vm.setQuery(field ? { ...rest, sort: { field, direction: "asc" } } : rest);
}}
>
<option value="">Tri par défaut</option>
{(Object.keys(SORT_FIELD_LABEL) as TicketListSortField[]).map((f) => (
<option key={f} value={f}>
{SORT_FIELD_LABEL[f]}
</option>
))}
</select>
</div>
</div>
{/* ── List, grouped by sprint ── */}
{vm.busy && vm.list === null ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
<Spinner size={12} /> Chargement des tickets
</span>
) : items.length === 0 && !hasFilters ? (
<div className="flex flex-col items-start gap-2">
<p className="text-sm text-muted">Aucun ticket dans ce projet.</p>
<Button size="sm" onClick={() => setMode({ type: "create" })}>
Créer un ticket
</Button>
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-start gap-2">
<p className="text-sm text-muted">Aucun ticket ne correspond aux filtres.</p>
<Button size="sm" variant="ghost" onClick={resetFilters}>
Réinitialiser les filtres
</Button>
</div>
) : (
<div className="flex flex-col gap-4" data-testid="web-ticket-list">
{grouped.map((g) => (
<TicketGroup
key={g.sprint.id}
heading={g.sprint.name}
tickets={g.tickets}
sprints={vm.sprints}
nameOf={webAgents.nameOf}
onOpen={(ref) => setMode({ type: "detail", ref })}
/>
))}
{noSprint.length > 0 && (
<TicketGroup
heading="Sans sprint"
tickets={noSprint}
sprints={vm.sprints}
nameOf={webAgents.nameOf}
onOpen={(ref) => setMode({ type: "detail", ref })}
/>
)}
</div>
)}
{vm.list?.nextCursor && (
<div className="mt-3 flex justify-center">
<Button
size="sm"
variant="ghost"
loading={vm.busy}
onClick={() => vm.setQuery({ ...vm.query, limit: (vm.query.limit ?? 100) + 100 })}
>
Charger plus
</Button>
</div>
)}
</Panel>
);
}
function TicketGroup({
heading,
tickets,
sprints,
nameOf,
onOpen,
}: {
heading: string;
tickets: TicketSummary[];
sprints: Sprint[];
nameOf: (id: string) => string;
onOpen: (ref: string) => void;
}) {
const sprintNameOf = (id: string | null | undefined) =>
id ? sprints.find((s) => s.id === id)?.name : undefined;
return (
<section aria-label={`Tickets — ${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">
{tickets.length}
</span>
</h3>
<ul className="flex flex-col divide-y divide-border">
{tickets.map((t) => {
const sprintName = sprintNameOf(t.sprintId);
const assignees = t.assignedAgentIds.map(nameOf).join(", ");
return (
<li key={t.ref}>
<button
type="button"
onClick={() => onOpen(t.ref)}
aria-label={`${t.ref}, ${t.title}, statut ${webStatusLabel(t.status)}, priorité ${webPriorityLabel(t.priority)}`}
className="flex w-full min-w-0 flex-col items-start gap-0.5 rounded-md px-2 py-2 text-left transition-colors hover:bg-raised"
>
<span className="flex w-full min-w-0 items-baseline gap-2">
<code className="shrink-0 rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-content">
{t.ref}
</code>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
{t.title}
</span>
</span>
<span className="flex flex-wrap items-center gap-1.5 text-xs text-muted">
<WebStatusBadge status={t.status} />
<WebPriorityBadge priority={t.priority} />
{sprintName && <span>· {sprintName}</span>}
{assignees && <span>· {assignees}</span>}
</span>
</button>
</li>
);
})}
</ul>
</section>
);
}

View File

@ -0,0 +1,41 @@
/**
* Minimal `{id, name}` agent roster for the web tickets surface (ticket #86).
*
* NOT a reuse of `@/features/tickets/useProjectAgents`: that hook calls
* `AgentGateway.listAgents` (`list_agents`), which is not in the web-server's
* `/api/invoke` allowlist (desktop/Tauri-only surface — confirmed absent from
* `crates/web-server/src/lib.rs`'s command dispatch). Adding it there is a
* backend change outside this frontend-only lot.
*
* Workaround: derive the roster from `get_project_work_state`, which IS
* allowlisted for web (the Live tab already depends on it) and already carries
* `agentId` + `name` for every manifest agent. This only surfaces agents that
* have a work-state row; on this codebase that is every declared project
* agent, matching what `listAgents` would return.
*/
import { useMemo } from "react";
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
export interface WebProjectAgent {
id: string;
name: string;
}
export interface WebProjectAgents {
agents: WebProjectAgent[];
nameOf: (agentId: string) => string;
loaded: boolean;
}
export function useWebProjectAgents(projectId: string): WebProjectAgents {
const vm = useProjectWorkState(projectId);
const agents = useMemo(
() => (vm.state?.agents ?? []).map((a) => ({ id: a.agentId, name: a.name })),
[vm.state],
);
const byId = useMemo(() => new Map(agents.map((a) => [a.id, a.name])), [agents]);
const nameOf = (agentId: string): string => byId.get(agentId) ?? `${agentId.slice(0, 8)}`;
return { agents, nameOf, loaded: vm.state !== null };
}

View File

@ -0,0 +1,100 @@
/**
* French presentation helpers for the web tickets/sprints surface (ticket #86).
*
* Deliberately **not** a reuse of `@/features/tickets/ticketMeta`: that module's
* `statusLabel`/`priorityLabel`/badges render English text for the desktop
* surface. Per carnet #86 (French UI by default, decision #78) the web surface
* needs its own French labels — a separate, tiny module rather than adding an
* i18n layer to the shared desktop component. The pure type/constant arrays
* (`TICKET_STATUSES`, `TICKET_PRIORITIES`, `TICKET_LINK_KINDS`) ARE reused from
* `ticketMeta` since they carry no English text.
*/
import type { TicketLinkKind, TicketPriority, TicketStatus } from "@/domain";
import { cn } from "@/shared";
export function webStatusLabel(status: TicketStatus): string {
switch (status) {
case "open":
return "Ouvert";
case "inProgress":
return "En cours";
case "QA":
return "QA";
case "closed":
return "Fermé";
}
}
export function webPriorityLabel(priority: TicketPriority): string {
switch (priority) {
case "low":
return "Faible";
case "medium":
return "Moyenne";
case "high":
return "Haute";
case "critical":
return "Critique";
}
}
export function webLinkKindLabel(kind: TicketLinkKind): string {
switch (kind) {
case "relatesTo":
return "en relation avec";
case "blocks":
return "bloque";
case "blockedBy":
return "bloqué par";
case "duplicates":
return "duplique";
case "dependsOn":
return "dépend de";
}
}
function statusClass(status: TicketStatus): string {
switch (status) {
case "open":
return "bg-raised text-muted";
case "inProgress":
return "bg-warning/15 text-warning";
case "QA":
return "bg-primary/15 text-primary";
case "closed":
return "bg-success/15 text-success";
}
}
function priorityClass(priority: TicketPriority): string {
switch (priority) {
case "low":
return "bg-raised text-muted";
case "medium":
return "bg-primary/10 text-primary";
case "high":
return "bg-warning/15 text-warning";
case "critical":
return "bg-danger/15 text-danger";
}
}
function badgeBase(className?: string): string {
return cn(
"inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium",
className,
);
}
export function WebStatusBadge({ status }: { status: TicketStatus }) {
return <span className={badgeBase(statusClass(status))}>{webStatusLabel(status)}</span>;
}
export function WebPriorityBadge({ priority }: { priority: TicketPriority }) {
return (
<span className={badgeBase(priorityClass(priority))}>
{webPriorityLabel(priority)}
</span>
);
}