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:
@ -36,4 +36,7 @@ export {
|
|||||||
statusLabel,
|
statusLabel,
|
||||||
priorityLabel,
|
priorityLabel,
|
||||||
linkKindLabel,
|
linkKindLabel,
|
||||||
|
TICKET_STATUSES,
|
||||||
|
TICKET_PRIORITIES,
|
||||||
|
TICKET_LINK_KINDS,
|
||||||
} from "./ticketMeta";
|
} from "./ticketMeta";
|
||||||
|
|||||||
@ -48,6 +48,12 @@ export interface TicketDetailViewModel {
|
|||||||
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
|
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
|
||||||
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
|
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
|
||||||
assign: (agentId: string, assigned: boolean) => 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
|
* Deletes this ticket (ticket #6). Returns `true` on success. The removal from
|
||||||
* lists and the closing of this surface flow from the resulting `issueDeleted`
|
* lists and the closing of this surface flow from the resulting `issueDeleted`
|
||||||
@ -205,6 +211,12 @@ export function useTicketDetail(
|
|||||||
[run, gateway, projectId, ref],
|
[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 () => {
|
const remove: TicketDetailViewModel["remove"] = useCallback(async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -234,6 +246,7 @@ export function useTicketDetail(
|
|||||||
link,
|
link,
|
||||||
unlink,
|
unlink,
|
||||||
assign,
|
assign,
|
||||||
|
setSprint,
|
||||||
remove,
|
remove,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,8 @@ import { Button, Panel, Spinner, cn } from "@/shared";
|
|||||||
import { WebAgentCell } from "./WebAgentCell";
|
import { WebAgentCell } from "./WebAgentCell";
|
||||||
import { useLiveReconnect } from "./useLiveReconnect";
|
import { useLiveReconnect } from "./useLiveReconnect";
|
||||||
import { useLiveConnectionState } from "./useLiveConnectionState";
|
import { useLiveConnectionState } from "./useLiveConnectionState";
|
||||||
|
import { WebTicketsView } from "./tickets/WebTicketsView";
|
||||||
|
import { WebSprintsView } from "./tickets/WebSprintsView";
|
||||||
|
|
||||||
function describe(e: unknown): string {
|
function describe(e: unknown): string {
|
||||||
if (e && typeof e === "object" && "message" in e) {
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
@ -118,12 +120,109 @@ export function WebWorkspace() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{openId && (
|
{openId && (
|
||||||
<LiveProjectPanel projectId={openId} root={openRoot} />
|
<ProjectTabs projectId={openId} root={openRoot} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</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é"
|
* 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
|
* notice into xterm, but live-only surfaces (no terminal open) had no visible
|
||||||
|
|||||||
78
frontend/src/features/web/tickets/WebConfirmDialog.tsx
Normal file
78
frontend/src/features/web/tickets/WebConfirmDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
311
frontend/src/features/web/tickets/WebSprintsView.tsx
Normal file
311
frontend/src/features/web/tickets/WebSprintsView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
frontend/src/features/web/tickets/WebTicketCreate.tsx
Normal file
141
frontend/src/features/web/tickets/WebTicketCreate.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
460
frontend/src/features/web/tickets/WebTicketDetail.tsx
Normal file
460
frontend/src/features/web/tickets/WebTicketDetail.tsx
Normal 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 été 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
frontend/src/features/web/tickets/WebTicketPickerSheet.tsx
Normal file
207
frontend/src/features/web/tickets/WebTicketPickerSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
448
frontend/src/features/web/tickets/WebTicketsSprints.test.tsx
Normal file
448
frontend/src/features/web/tickets/WebTicketsSprints.test.tsx
Normal 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
333
frontend/src/features/web/tickets/WebTicketsView.tsx
Normal file
333
frontend/src/features/web/tickets/WebTicketsView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/src/features/web/tickets/useWebProjectAgents.ts
Normal file
41
frontend/src/features/web/tickets/useWebProjectAgents.ts
Normal 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 };
|
||||||
|
}
|
||||||
100
frontend/src/features/web/tickets/webTicketLabels.tsx
Normal file
100
frontend/src/features/web/tickets/webTicketLabels.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user