merge feature/4-cellgrid-refresh-on-layout-change dans develop (LayoutGrid refitEpoch)

This commit is contained in:
2026-07-28 19:15:12 +02:00
10 changed files with 572 additions and 158 deletions

View File

@ -0,0 +1,136 @@
import { useEffect } from "react";
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import type { Gateways } from "@/ports";
import { MockLayoutGateway, MockTerminalGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
const terminalViewSpy = vi.hoisted(() => vi.fn());
const terminalMountSpy = vi.hoisted(() => vi.fn());
const terminalUnmountSpy = vi.hoisted(() => vi.fn());
vi.mock("@/features/terminals", () => ({
TerminalView: (props: { refitSignal?: number }) => {
terminalViewSpy(props);
useEffect(() => {
terminalMountSpy();
return () => terminalUnmountSpy();
}, []);
return (
<div
data-testid="mock-terminal-view"
data-refit-signal={String(props.refitSignal)}
/>
);
},
ResumeConversationPopup: () => null,
useWritePortal: () => ({
portal: {
onHumanData: () => {},
isSuspended: () => false,
bindHandle: () => {},
unbindHandle: () => {},
},
overlay: false,
}),
}));
import { LayoutGrid } from "./LayoutGrid";
function renderGrid(
layout: MockLayoutGateway,
props: { projectId: string; cwd: string; layoutId?: string },
) {
const gateways = {
layout,
terminal: new MockTerminalGateway(),
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<LayoutGrid {...props} />
</DIProvider>,
);
}
function latestRefitSignal(): number {
const lastCall = terminalViewSpy.mock.calls.at(-1);
return Number(lastCall?.[0]?.refitSignal ?? -1);
}
describe("LayoutGrid refit epoch (#4)", () => {
it("bumps TerminalView refitSignal after cwd/layout/project transitions", async () => {
const layout = new MockLayoutGateway();
const { activeId: firstLayoutId } = await layout.listLayouts("p1");
const { layoutId: secondLayoutId } = await layout.createLayout("p1", "Second");
terminalViewSpy.mockClear();
terminalMountSpy.mockClear();
terminalUnmountSpy.mockClear();
const view = renderGrid(layout, {
projectId: "p1",
cwd: "/same/cwd",
layoutId: firstLayoutId,
});
await screen.findByTestId("mock-terminal-view");
await waitFor(() => expect(latestRefitSignal()).toBeGreaterThan(0));
const initialSignal = latestRefitSignal();
expect(terminalMountSpy).toHaveBeenCalledTimes(1);
view.rerender(
<DIProvider
gateways={{
layout,
terminal: new MockTerminalGateway(),
} as unknown as Gateways}
>
<LayoutGrid
projectId="p1"
cwd="/same/cwd/after-transition"
layoutId={firstLayoutId}
/>
</DIProvider>,
);
await waitFor(() => expect(latestRefitSignal()).toBeGreaterThan(initialSignal));
const afterCwdSignal = latestRefitSignal();
expect(terminalMountSpy).toHaveBeenCalledTimes(1);
expect(terminalUnmountSpy).not.toHaveBeenCalled();
view.rerender(
<DIProvider
gateways={{
layout,
terminal: new MockTerminalGateway(),
} as unknown as Gateways}
>
<LayoutGrid
projectId="p1"
cwd="/same/cwd/after-transition"
layoutId={secondLayoutId}
/>
</DIProvider>,
);
await waitFor(() => expect(latestRefitSignal()).toBeGreaterThan(afterCwdSignal));
const afterLayoutSwitchSignal = latestRefitSignal();
view.rerender(
<DIProvider
gateways={{
layout,
terminal: new MockTerminalGateway(),
} as unknown as Gateways}
>
<LayoutGrid projectId="p2" cwd="/same/cwd" />
</DIProvider>,
);
await waitFor(() =>
expect(latestRefitSignal()).toBeGreaterThan(afterLayoutSwitchSignal),
);
expect(terminalMountSpy).toHaveBeenCalled();
});
});

View File

@ -97,6 +97,11 @@ export function LayoutGrid({
}: LayoutGridProps) { }: LayoutGridProps) {
const vm = useLayout(projectId, layoutId); const vm = useLayout(projectId, layoutId);
const work = useProjectWorkState(projectId); const work = useProjectWorkState(projectId);
const [refitEpoch, setRefitEpoch] = useState(0);
useEffect(() => {
setRefitEpoch((epoch) => epoch + 1);
}, [projectId, layoutId, cwd, vm.layoutVersion]);
if (!vm.layout) { if (!vm.layout) {
return ( return (
@ -129,6 +134,7 @@ export function LayoutGrid({
projectId={projectId} projectId={projectId}
workState={work.state} workState={work.state}
refreshWorkState={work.refresh} refreshWorkState={work.refresh}
refitSignal={refitEpoch}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings} onOpenPluginsSettings={onOpenPluginsSettings}
/> />
@ -145,6 +151,7 @@ interface NodeViewProps {
projectId: string; projectId: string;
workState: ProjectWorkState | null; workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>; refreshWorkState: () => Promise<void>;
refitSignal: number;
onOpenConversation?: (conversationId: string) => void; onOpenConversation?: (conversationId: string) => void;
onOpenPluginsSettings?: () => void; onOpenPluginsSettings?: () => void;
} }
@ -157,6 +164,7 @@ function NodeView({
projectId, projectId,
workState, workState,
refreshWorkState, refreshWorkState,
refitSignal,
onOpenConversation, onOpenConversation,
onOpenPluginsSettings, onOpenPluginsSettings,
}: NodeViewProps) { }: NodeViewProps) {
@ -190,6 +198,7 @@ function NodeView({
projectId={projectId} projectId={projectId}
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
/> />
); );
@ -202,6 +211,7 @@ function NodeView({
projectId={projectId} projectId={projectId}
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings} onOpenPluginsSettings={onOpenPluginsSettings}
/> />
@ -215,6 +225,7 @@ function NodeView({
projectId={projectId} projectId={projectId}
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings} onOpenPluginsSettings={onOpenPluginsSettings}
/> />
@ -234,6 +245,7 @@ interface LeafViewProps {
projectId: string; projectId: string;
workState: ProjectWorkState | null; workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>; refreshWorkState: () => Promise<void>;
refitSignal: number;
onOpenConversation?: (conversationId: string) => void; onOpenConversation?: (conversationId: string) => void;
} }
@ -315,6 +327,7 @@ function LeafView({
projectId, projectId,
workState, workState,
refreshWorkState, refreshWorkState,
refitSignal,
onOpenConversation, onOpenConversation,
}: LeafViewProps) { }: LeafViewProps) {
// A cell can be closed only when it lives inside a (binary) split: closing it // A cell can be closed only when it lives inside a (binary) split: closing it
@ -950,7 +963,7 @@ function LeafView({
onSessionId={(sid) => void vm.setSession(id, sid)} onSessionId={(sid) => void vm.setSession(id, sid)}
agentMode={agentId != null} agentMode={agentId != null}
portal={agentId != null ? portal : undefined} portal={agentId != null ? portal : undefined}
refitSignal={vm.layoutVersion} refitSignal={refitSignal}
/> />
{/* Write-portal overlay (ARCHITECTURE §20.3 step b/e): while a delegation {/* Write-portal overlay (ARCHITECTURE §20.3 step b/e): while a delegation
is being injected into the agent's PTY, a grey veil with a centred is being injected into the agent's PTY, a grey veil with a centred
@ -1185,6 +1198,7 @@ interface SplitViewProps {
projectId: string; projectId: string;
workState: ProjectWorkState | null; workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>; refreshWorkState: () => Promise<void>;
refitSignal: number;
onOpenConversation?: (conversationId: string) => void; onOpenConversation?: (conversationId: string) => void;
onOpenPluginsSettings?: () => void; onOpenPluginsSettings?: () => void;
} }
@ -1196,6 +1210,7 @@ function SplitView({
projectId, projectId,
workState, workState,
refreshWorkState, refreshWorkState,
refitSignal,
onOpenConversation, onOpenConversation,
onOpenPluginsSettings, onOpenPluginsSettings,
}: SplitViewProps) { }: SplitViewProps) {
@ -1241,6 +1256,7 @@ function SplitView({
projectId={projectId} projectId={projectId}
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings} onOpenPluginsSettings={onOpenPluginsSettings}
parentSplit={{ parentSplit={{
@ -1336,6 +1352,7 @@ interface GridViewProps {
projectId: string; projectId: string;
workState: ProjectWorkState | null; workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>; refreshWorkState: () => Promise<void>;
refitSignal: number;
onOpenConversation?: (conversationId: string) => void; onOpenConversation?: (conversationId: string) => void;
onOpenPluginsSettings?: () => void; onOpenPluginsSettings?: () => void;
} }
@ -1347,6 +1364,7 @@ function GridView({
projectId, projectId,
workState, workState,
refreshWorkState, refreshWorkState,
refitSignal,
onOpenConversation, onOpenConversation,
onOpenPluginsSettings, onOpenPluginsSettings,
}: GridViewProps) { }: GridViewProps) {
@ -1388,6 +1406,7 @@ function GridView({
projectId={projectId} projectId={projectId}
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings} onOpenPluginsSettings={onOpenPluginsSettings}
/> />

View File

@ -11,14 +11,9 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Button, Spinner, cn } from "@/shared"; import { Button, Spinner, cn } from "@/shared";
import { TicketViewportSelect } from "./TicketViewportSelect";
import { useTicketAssistant } from "./useTicketAssistant"; import { useTicketAssistant } from "./useTicketAssistant";
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",
);
export interface TicketAssistantPanelProps { export interface TicketAssistantPanelProps {
projectId: string; projectId: string;
ticketRef: string; ticketRef: string;
@ -53,24 +48,25 @@ export function TicketAssistantPanel({
{!open ? ( {!open ? (
// ── Session opener ── // ── Session opener ──
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<select <TicketViewportSelect
aria-label="profil de l'assistant" aria-label="profil de l'assistant"
className={selectClass}
value={profileId} value={profileId}
disabled={vm.opening || vm.profiles.length === 0} disabled={vm.opening || vm.profiles.length === 0}
onChange={(e) => setProfileId(e.target.value)} options={[
> {
<option value=""> value: "",
{vm.profiles.length === 0 label:
? "Aucun profil disponible" vm.profiles.length === 0
: "Choisir un profil IA…"} ? "Aucun profil disponible"
</option> : "Choisir un profil IA…",
{vm.profiles.map((p) => ( },
<option key={p.id} value={p.id}> ...vm.profiles.map((profile) => ({
{p.name} value: profile.id,
</option> label: profile.name,
))} })),
</select> ]}
onChange={setProfileId}
/>
<Button <Button
size="sm" size="sm"
disabled={!profileId || vm.opening} disabled={!profileId || vm.opening}

View File

@ -17,6 +17,7 @@ import { useTicketDetail } from "./useTicketDetail";
import { useProjectAgents } from "./useProjectAgents"; import { useProjectAgents } from "./useProjectAgents";
import { TicketAssistantPanel } from "./TicketAssistantPanel"; import { TicketAssistantPanel } from "./TicketAssistantPanel";
import { TicketPicker } from "./TicketPicker"; import { TicketPicker } from "./TicketPicker";
import { TicketViewportSelect } from "./TicketViewportSelect";
import { import {
PriorityBadge, PriorityBadge,
StatusBadge, StatusBadge,
@ -30,12 +31,6 @@ import {
statusLabel, statusLabel,
} from "./ticketMeta"; } from "./ticketMeta";
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( const textareaClass = cn(
"w-full rounded-md bg-raised p-3 text-sm text-content", "w-full rounded-md bg-raised p-3 text-sm text-content",
"border border-border outline-none transition-colors", "border border-border outline-none transition-colors",
@ -320,44 +315,38 @@ export function TicketDetail({
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-xs text-muted"> <label className="flex items-center gap-2 text-xs text-muted">
Status Status
<select <TicketViewportSelect
aria-label="ticket status" aria-label="ticket status"
className={selectClass}
value={t.status} value={t.status}
disabled={vm.busy} disabled={vm.busy}
onChange={(e) => options={TICKET_STATUSES.map((status) => ({
value: status,
label: statusLabel(status),
}))}
onChange={(next) =>
void vm.updateFields({ void vm.updateFields({
status: e.target.value as (typeof TICKET_STATUSES)[number], status: next as (typeof TICKET_STATUSES)[number],
}) })
} }
> />
{TICKET_STATUSES.map((s) => (
<option key={s} value={s}>
{statusLabel(s)}
</option>
))}
</select>
</label> </label>
<label className="flex items-center gap-2 text-xs text-muted"> <label className="flex items-center gap-2 text-xs text-muted">
Priority Priority
<select <TicketViewportSelect
aria-label="ticket priority" aria-label="ticket priority"
className={selectClass}
value={t.priority} value={t.priority}
disabled={vm.busy} disabled={vm.busy}
onChange={(e) => options={TICKET_PRIORITIES.map((priority) => ({
value: priority,
label: priorityLabel(priority),
}))}
onChange={(next) =>
void vm.updateFields({ void vm.updateFields({
priority: priority:
e.target.value as (typeof TICKET_PRIORITIES)[number], next as (typeof TICKET_PRIORITIES)[number],
}) })
} }
> />
{TICKET_PRIORITIES.map((p) => (
<option key={p} value={p}>
{priorityLabel(p)}
</option>
))}
</select>
</label> </label>
</div> </div>
</Section> </Section>
@ -426,19 +415,16 @@ export function TicketDetail({
{/* Add a link via the TicketPicker popup (#17): the kind is chosen {/* Add a link via the TicketPicker popup (#17): the kind is chosen
here, the target ticket is selected in the popup (no manual #id). */} here, the target ticket is selected in the popup (no manual #id). */}
<div className="mt-1 flex flex-wrap items-center gap-2"> <div className="mt-1 flex flex-wrap items-center gap-2">
<select <TicketViewportSelect
aria-label="link kind" aria-label="link kind"
className={selectClass}
value={linkKind} value={linkKind}
disabled={vm.busy} disabled={vm.busy}
onChange={(e) => setLinkKind(e.target.value as TicketLinkKind)} options={TICKET_LINK_KINDS.map((kind) => ({
> value: kind,
{TICKET_LINK_KINDS.map((k) => ( label: linkKindLabel(kind),
<option key={k} value={k}> }))}
{linkKindLabel(k)} onChange={(next) => setLinkKind(next as TicketLinkKind)}
</option> />
))}
</select>
<Button <Button
size="sm" size="sm"
aria-label="add link" aria-label="add link"
@ -479,24 +465,25 @@ export function TicketDetail({
</ul> </ul>
)} )}
<div className="mt-1 flex items-center gap-2"> <div className="mt-1 flex items-center gap-2">
<select <TicketViewportSelect
aria-label="assign agent" aria-label="assign agent"
className={selectClass}
value={assignPick} value={assignPick}
disabled={vm.busy || assignable.length === 0} disabled={vm.busy || assignable.length === 0}
onChange={(e) => setAssignPick(e.target.value)} options={[
> {
<option value=""> value: "",
{assignable.length === 0 label:
? "No more agents" assignable.length === 0
: "Select an agent"} ? "No more agents"
</option> : "Select an agent…",
{assignable.map((a) => ( },
<option key={a.id} value={a.id}> ...assignable.map((agent) => ({
{a.name} value: agent.id,
</option> label: agent.name,
))} })),
</select> ]}
onChange={setAssignPick}
/>
<Button <Button
size="sm" size="sm"
disabled={!assignPick || vm.busy} disabled={!assignPick || vm.busy}

View File

@ -15,7 +15,8 @@
import type { TicketPriority, TicketStatus } from "@/domain"; import type { TicketPriority, TicketStatus } from "@/domain";
import type { TicketListSort, TicketListSortField } from "@/ports"; import type { TicketListSort, TicketListSortField } from "@/ports";
import { Button, Input, cn } from "@/shared"; import { Button, Input } from "@/shared";
import { TicketViewportSelect } from "./TicketViewportSelect";
import { import {
PriorityBadge, PriorityBadge,
StatusBadge, StatusBadge,
@ -25,13 +26,6 @@ import {
statusLabel, statusLabel,
} from "./ticketMeta"; } from "./ticketMeta";
/** Styling for the sort-field select, matching the panel's other selects. */
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",
);
/** Human labels for the sort fields (also the option text). */ /** Human labels for the sort fields (also the option text). */
const SORT_FIELD_LABEL: Record<TicketListSortField, string> = { const SORT_FIELD_LABEL: Record<TicketListSortField, string> = {
number: "Numéro", number: "Numéro",
@ -146,23 +140,22 @@ export function TicketFacetsBar({
> >
<label className="flex items-center gap-1.5 text-xs text-muted"> <label className="flex items-center gap-1.5 text-xs text-muted">
Trier par Trier par
<select <TicketViewportSelect
aria-label="sort tickets by" aria-label="sort tickets by"
className={selectClass}
value={sort?.field ?? ""} value={sort?.field ?? ""}
onChange={(e) => { options={[
const field = e.target.value as TicketListSortField | ""; { value: "", label: "Par défaut" },
...SORT_FIELDS.map((field) => ({
value: field,
label: SORT_FIELD_LABEL[field],
})),
]}
onChange={(next) => {
const field = next as TicketListSortField | "";
if (field === "") onSortChange(undefined); if (field === "") onSortChange(undefined);
else onSortChange({ field, direction: sort?.direction ?? "asc" }); else onSortChange({ field, direction: sort?.direction ?? "asc" });
}} }}
> />
<option value="">Par défaut</option>
{SORT_FIELDS.map((f) => (
<option key={f} value={f}>
{SORT_FIELD_LABEL[f]}
</option>
))}
</select>
</label> </label>
{sort && ( {sort && (
<Button <Button

View File

@ -145,7 +145,8 @@ describe("TicketPicker (#18)", () => {
// The sort control is present in the picker too (shared TicketFacetsBar). // The sort control is present in the picker too (shared TicketFacetsBar).
const sortBy = screen.getByLabelText("sort tickets by"); const sortBy = screen.getByLabelText("sort tickets by");
fireEvent.change(sortBy, { target: { value: "title" } }); fireEvent.click(sortBy);
fireEvent.click(await screen.findByRole("option", { name: "Titre" }));
await waitFor(() => await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({ expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
field: "title", field: "title",

View File

@ -0,0 +1,105 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { zIndex } from "@/shared";
import { TicketViewportSelect } from "./TicketViewportSelect";
const options = [
{ value: "", label: "All" },
{ value: "alpha", label: "Alpha" },
{ value: "beta", label: "Beta" },
];
function setViewport(width: number, height: number) {
Object.defineProperty(window, "innerWidth", {
value: width,
configurable: true,
});
Object.defineProperty(window, "innerHeight", {
value: height,
configurable: true,
});
}
describe("TicketViewportSelect", () => {
it("renders the listbox in a portal and selects an option", async () => {
const onChange = vi.fn();
render(
<TicketViewportSelect
ariaLabel="filter by assignee"
value=""
options={options}
onChange={onChange}
/>,
);
const trigger = screen.getByLabelText("filter by assignee");
fireEvent.click(trigger);
const listbox = await screen.findByRole("listbox", {
name: "filter by assignee",
});
expect(listbox.parentElement).toBe(document.body);
expect(listbox.style.zIndex).toBe(String(zIndex.menuDropdown));
fireEvent.click(screen.getByRole("option", { name: "Alpha" }));
expect(onChange).toHaveBeenCalledWith("alpha");
expect(screen.queryByRole("listbox")).toBeNull();
});
it("flips above the trigger and bounds height near the viewport bottom", async () => {
setViewport(500, 220);
const onChange = vi.fn();
render(
<TicketViewportSelect
ariaLabel="sprint for #1"
value=""
options={options}
onChange={onChange}
/>,
);
const trigger = screen.getByLabelText("sprint for #1");
trigger.getBoundingClientRect = () =>
({
x: 20,
y: 190,
top: 190,
left: 20,
right: 140,
bottom: 218,
width: 120,
height: 28,
toJSON: () => {},
}) as DOMRect;
fireEvent.click(trigger);
const listbox = await screen.findByRole("listbox", { name: "sprint for #1" });
expect(listbox.getAttribute("data-placement")).toBe("top");
expect(Number.parseFloat(listbox.style.maxHeight)).toBeLessThanOrEqual(182);
expect(listbox.className).toContain("overflow-y-auto");
});
it("closes on Escape and outside pointer down", async () => {
render(
<TicketViewportSelect
ariaLabel="sort tickets by"
value=""
options={options}
onChange={vi.fn()}
/>,
);
fireEvent.click(screen.getByLabelText("sort tickets by"));
expect(await screen.findByRole("listbox")).toBeTruthy();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("listbox")).toBeNull();
fireEvent.click(screen.getByLabelText("sort tickets by"));
expect(await screen.findByRole("listbox")).toBeTruthy();
fireEvent.pointerDown(document.body);
expect(screen.queryByRole("listbox")).toBeNull();
});
});

View File

@ -0,0 +1,192 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn, zIndex } from "@/shared";
export interface TicketViewportSelectOption {
value: string;
label: string;
disabled?: boolean;
}
export interface TicketViewportSelectProps {
ariaLabel?: string;
"aria-label"?: string;
value: string;
options: TicketViewportSelectOption[];
onChange: (value: string) => void;
className?: string;
disabled?: boolean;
}
interface PopupPosition {
top: number;
left: number;
width: number;
maxHeight: number;
placement: "top" | "bottom";
}
const VIEWPORT_MARGIN = 8;
const MIN_POPUP_HEIGHT = 96;
const MAX_POPUP_HEIGHT = 280;
export function TicketViewportSelect({
ariaLabel: ariaLabelProp,
"aria-label": ariaLabelAttribute,
value,
options,
onChange,
className,
disabled = false,
}: TicketViewportSelectProps) {
const triggerRef = useRef<HTMLButtonElement | null>(null);
const popupRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<PopupPosition | null>(null);
const selected = options.find((option) => option.value === value) ?? options[0];
const ariaLabel = ariaLabelAttribute ?? ariaLabelProp ?? "select option";
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const popupWidth = Math.max(rect.width, 160);
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN;
const spaceAbove = rect.top - VIEWPORT_MARGIN;
const placement =
spaceBelow < MIN_POPUP_HEIGHT && spaceAbove > spaceBelow ? "top" : "bottom";
const available = Math.max(
MIN_POPUP_HEIGHT,
placement === "top" ? spaceAbove : spaceBelow,
);
const maxHeight = Math.min(MAX_POPUP_HEIGHT, available);
const left = Math.min(
Math.max(VIEWPORT_MARGIN, rect.left),
Math.max(VIEWPORT_MARGIN, viewportWidth - popupWidth - VIEWPORT_MARGIN),
);
const top =
placement === "top"
? Math.max(VIEWPORT_MARGIN, rect.top - maxHeight - 4)
: Math.min(viewportHeight - VIEWPORT_MARGIN, rect.bottom + 4);
setPosition({ top, left, width: popupWidth, maxHeight, placement });
}, []);
useLayoutEffect(() => {
if (!open) return;
updatePosition();
}, [open, updatePosition]);
useEffect(() => {
if (!open) return;
function onPointerDown(event: PointerEvent) {
const target = event.target as Node | null;
if (
target &&
(triggerRef.current?.contains(target) || popupRef.current?.contains(target))
) {
return;
}
setOpen(false);
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
triggerRef.current?.focus();
}
}
const onViewportChange = () => {
updatePosition();
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
window.addEventListener("resize", onViewportChange);
window.addEventListener("scroll", onViewportChange, true);
window.visualViewport?.addEventListener("resize", onViewportChange);
window.visualViewport?.addEventListener("scroll", onViewportChange);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onViewportChange);
window.removeEventListener("scroll", onViewportChange, true);
window.visualViewport?.removeEventListener("resize", onViewportChange);
window.visualViewport?.removeEventListener("scroll", onViewportChange);
};
}, [open, updatePosition]);
return (
<>
<button
ref={triggerRef}
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
className={cn(
"inline-flex h-8 items-center justify-between gap-2 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",
className,
)}
>
<span className="min-w-0 truncate">{selected?.label ?? ""}</span>
<span aria-hidden="true" className="text-muted"></span>
</button>
{open &&
position &&
createPortal(
<div
ref={popupRef}
role="listbox"
aria-label={ariaLabel}
data-placement={position.placement}
className="fixed overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-xl"
style={{
top: position.top,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
zIndex: zIndex.menuDropdown,
}}
>
{options.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={option.value === value}
disabled={option.disabled}
onClick={() => {
if (option.disabled) return;
onChange(option.value);
setOpen(false);
triggerRef.current?.focus();
}}
className={cn(
"flex min-h-8 w-full items-center px-2 text-left text-xs text-content",
"hover:bg-raised focus:bg-raised focus:outline-none",
option.value === value && "bg-raised",
option.disabled && "cursor-not-allowed opacity-50",
)}
>
<span className="min-w-0 truncate">{option.label}</span>
</button>
))}
</div>,
document.body,
)}
</>
);
}

View File

@ -10,12 +10,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { Sprint, TicketPriority, TicketSummary } from "@/domain"; import type { Sprint, TicketPriority, TicketSummary } from "@/domain";
import { Button, Input, Panel, Spinner, cn } from "@/shared"; import { Button, Input, Panel, Spinner } from "@/shared";
import { useTickets } from "./useTickets"; import { useTickets } from "./useTickets";
import { useProjectAgents } from "./useProjectAgents"; import { useProjectAgents } from "./useProjectAgents";
import { SprintManager } from "./SprintManager"; import { SprintManager } from "./SprintManager";
import { SprintPicker } from "./SprintPicker"; import { SprintPicker } from "./SprintPicker";
import { TicketFacetsBar } from "./TicketFacetsBar"; import { TicketFacetsBar } from "./TicketFacetsBar";
import { TicketViewportSelect } from "./TicketViewportSelect";
import { import {
PriorityBadge, PriorityBadge,
StatusBadge, StatusBadge,
@ -24,12 +25,6 @@ import {
priorityLabel, priorityLabel,
} from "./ticketMeta"; } from "./ticketMeta";
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",
);
export interface TicketsPanelProps { export interface TicketsPanelProps {
projectId: string; projectId: string;
/** Opens the detail overlay for the given `#ref` (F7). */ /** Opens the detail overlay for the given `#ref` (F7). */
@ -151,18 +146,15 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
onChange={(e) => setNewTitle(e.target.value)} onChange={(e) => setNewTitle(e.target.value)}
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select <TicketViewportSelect
aria-label="new ticket priority" aria-label="new ticket priority"
className={selectClass}
value={newPriority} value={newPriority}
onChange={(e) => setNewPriority(e.target.value as TicketPriority)} options={TICKET_PRIORITIES.map((priority) => ({
> value: priority,
{TICKET_PRIORITIES.map((p) => ( label: priorityLabel(priority),
<option key={p} value={p}> }))}
{priorityLabel(p)} onChange={(next) => setNewPriority(next as TicketPriority)}
</option> />
))}
</select>
<Button <Button
type="submit" type="submit"
size="sm" size="sm"
@ -211,24 +203,23 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
}} }}
/> />
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<select <TicketViewportSelect
aria-label="filter by assignee" aria-label="filter by assignee"
className={selectClass}
value={vm.query.assignedAgentId ?? ""} value={vm.query.assignedAgentId ?? ""}
onChange={(e) => options={[
{ value: "", label: "All assignees" },
...agents.map((agent) => ({
value: agent.id,
label: agent.name,
})),
]}
onChange={(next) =>
vm.setQuery({ vm.setQuery({
...vm.query, ...vm.query,
assignedAgentId: e.target.value || undefined, assignedAgentId: next || undefined,
}) })
} }
> />
<option value="">All assignees</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</div> </div>
</div> </div>
@ -363,21 +354,20 @@ function SprintSection({
</span> </span>
</div> </div>
<div className="mt-1 pl-6"> <div className="mt-1 pl-6">
<select <TicketViewportSelect
aria-label={`sprint for ${t.ref}`} aria-label={`sprint for ${t.ref}`}
className={selectClass}
value={t.sprintId ?? ""} value={t.sprintId ?? ""}
onChange={(e) => options={[
onAssignSprint(t.ref, e.target.value || null) { value: "", label: "— No sprint —" },
...sprints.map((sprint) => ({
value: sprint.id,
label: sprint.name,
})),
]}
onChange={(next) =>
onAssignSprint(t.ref, next || null)
} }
> />
<option value=""> No sprint </option>
{sprints.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div> </div>
</li> </li>
))} ))}

View File

@ -353,9 +353,8 @@ describe("TicketsView", () => {
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined(); expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined();
// Choose a field ⇒ ascending by default, relayed in the query. // Choose a field ⇒ ascending by default, relayed in the query.
fireEvent.change(screen.getByLabelText("sort tickets by"), { fireEvent.click(screen.getByLabelText("sort tickets by"));
target: { value: "title" }, fireEvent.click(await screen.findByRole("option", { name: "Titre" }));
});
await waitFor(() => await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({ expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
field: "title", field: "title",
@ -375,9 +374,8 @@ describe("TicketsView", () => {
); );
// Back to « Par défaut » ⇒ `sort` dropped from the query again. // Back to « Par défaut » ⇒ `sort` dropped from the query again.
fireEvent.change(screen.getByLabelText("sort tickets by"), { fireEvent.click(screen.getByLabelText("sort tickets by"));
target: { value: "" }, fireEvent.click(await screen.findByRole("option", { name: "Par défaut" }));
});
await waitFor(() => await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined(), expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined(),
); );
@ -390,8 +388,8 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("Editable")); fireEvent.click(await screen.findByText("Editable"));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
const statusSelect = within(dialog).getByLabelText("ticket status"); fireEvent.click(within(dialog).getByLabelText("ticket status"));
fireEvent.change(statusSelect, { target: { value: "inProgress" } }); fireEvent.click(await screen.findByRole("option", { name: "In progress" }));
await waitFor(async () => { await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref); const fresh = await ticket.read(PROJECT_ID, t.ref);
@ -426,9 +424,8 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("Assignable")); fireEvent.click(await screen.findByText("Assignable"));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByLabelText("assign agent"), { fireEvent.click(within(dialog).getByLabelText("assign agent"));
target: { value: known.id }, fireEvent.click(await screen.findByRole("option", { name: "Backend" }));
});
fireEvent.click(within(dialog).getByText("Assign")); fireEvent.click(within(dialog).getByText("Assign"));
await waitFor(async () => { await waitFor(async () => {
@ -495,9 +492,8 @@ describe("TicketsView", () => {
// Change the priority — an immediate-apply mutation that re-fetches the ticket // Change the priority — an immediate-apply mutation that re-fetches the ticket
// and bumps its version. The unsaved draft must survive. // and bumps its version. The unsaved draft must survive.
fireEvent.change(within(dialog).getByLabelText("ticket priority"), { fireEvent.click(within(dialog).getByLabelText("ticket priority"));
target: { value: "high" }, fireEvent.click(await screen.findByRole("option", { name: "High" }));
});
// The backend applied the priority bump… // The backend applied the priority bump…
await waitFor(async () => { await waitFor(async () => {
@ -643,9 +639,8 @@ describe("TicketsView", () => {
expect(within(bucket).getByText("Movable")).toBeTruthy(); expect(within(bucket).getByText("Movable")).toBeTruthy();
// Pick the sprint in the row selector → assign it. // Pick the sprint in the row selector → assign it.
fireEvent.change(screen.getByLabelText(`sprint for ${t.ref}`), { fireEvent.click(screen.getByLabelText(`sprint for ${t.ref}`));
target: { value: "s1" }, fireEvent.click(await screen.findByRole("option", { name: "Sprint One" }));
});
// The gateway recorded the membership… // The gateway recorded the membership…
await waitFor(async () => { await waitFor(async () => {
@ -777,7 +772,8 @@ describe("TicketsView", () => {
); );
expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy(); expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy();
fireEvent.change(profileSelect, { target: { value: "qa-assistant" } }); fireEvent.click(profileSelect);
fireEvent.click(await screen.findByRole("option", { name: "QA Assistant" }));
fireEvent.click(within(assistant).getByText("Ouvrir la conversation")); fireEvent.click(within(assistant).getByText("Ouvrir la conversation"));
expect( expect(
@ -837,9 +833,8 @@ describe("TicketsView", () => {
expect(within(detail).queryByLabelText("link target ref")).toBeNull(); expect(within(detail).queryByLabelText("link target ref")).toBeNull();
// Choose the link kind, then open the picker and select the target ticket. // Choose the link kind, then open the picker and select the target ticket.
fireEvent.change(within(detail).getByLabelText("link kind"), { fireEvent.click(within(detail).getByLabelText("link kind"));
target: { value: "blocks" }, fireEvent.click(await screen.findByRole("option", { name: "blocks" }));
});
fireEvent.click(within(detail).getByLabelText("add link")); fireEvent.click(within(detail).getByLabelText("add link"));
// The picker excludes the ticket itself; the target is offered. // The picker excludes the ticket itself; the target is offered.