merge feature/4-cellgrid-refresh-on-layout-change dans develop (LayoutGrid refitEpoch)
This commit is contained in:
136
frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx
Normal file
136
frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -97,6 +97,11 @@ export function LayoutGrid({
|
||||
}: LayoutGridProps) {
|
||||
const vm = useLayout(projectId, layoutId);
|
||||
const work = useProjectWorkState(projectId);
|
||||
const [refitEpoch, setRefitEpoch] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setRefitEpoch((epoch) => epoch + 1);
|
||||
}, [projectId, layoutId, cwd, vm.layoutVersion]);
|
||||
|
||||
if (!vm.layout) {
|
||||
return (
|
||||
@ -129,6 +134,7 @@ export function LayoutGrid({
|
||||
projectId={projectId}
|
||||
workState={work.state}
|
||||
refreshWorkState={work.refresh}
|
||||
refitSignal={refitEpoch}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onOpenPluginsSettings={onOpenPluginsSettings}
|
||||
/>
|
||||
@ -145,6 +151,7 @@ interface NodeViewProps {
|
||||
projectId: string;
|
||||
workState: ProjectWorkState | null;
|
||||
refreshWorkState: () => Promise<void>;
|
||||
refitSignal: number;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
onOpenPluginsSettings?: () => void;
|
||||
}
|
||||
@ -157,6 +164,7 @@ function NodeView({
|
||||
projectId,
|
||||
workState,
|
||||
refreshWorkState,
|
||||
refitSignal,
|
||||
onOpenConversation,
|
||||
onOpenPluginsSettings,
|
||||
}: NodeViewProps) {
|
||||
@ -190,6 +198,7 @@ function NodeView({
|
||||
projectId={projectId}
|
||||
workState={workState}
|
||||
refreshWorkState={refreshWorkState}
|
||||
refitSignal={refitSignal}
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>
|
||||
);
|
||||
@ -202,6 +211,7 @@ function NodeView({
|
||||
projectId={projectId}
|
||||
workState={workState}
|
||||
refreshWorkState={refreshWorkState}
|
||||
refitSignal={refitSignal}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onOpenPluginsSettings={onOpenPluginsSettings}
|
||||
/>
|
||||
@ -215,6 +225,7 @@ function NodeView({
|
||||
projectId={projectId}
|
||||
workState={workState}
|
||||
refreshWorkState={refreshWorkState}
|
||||
refitSignal={refitSignal}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onOpenPluginsSettings={onOpenPluginsSettings}
|
||||
/>
|
||||
@ -234,6 +245,7 @@ interface LeafViewProps {
|
||||
projectId: string;
|
||||
workState: ProjectWorkState | null;
|
||||
refreshWorkState: () => Promise<void>;
|
||||
refitSignal: number;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
}
|
||||
|
||||
@ -315,6 +327,7 @@ function LeafView({
|
||||
projectId,
|
||||
workState,
|
||||
refreshWorkState,
|
||||
refitSignal,
|
||||
onOpenConversation,
|
||||
}: LeafViewProps) {
|
||||
// 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)}
|
||||
agentMode={agentId != null}
|
||||
portal={agentId != null ? portal : undefined}
|
||||
refitSignal={vm.layoutVersion}
|
||||
refitSignal={refitSignal}
|
||||
/>
|
||||
{/* 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
|
||||
@ -1185,6 +1198,7 @@ interface SplitViewProps {
|
||||
projectId: string;
|
||||
workState: ProjectWorkState | null;
|
||||
refreshWorkState: () => Promise<void>;
|
||||
refitSignal: number;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
onOpenPluginsSettings?: () => void;
|
||||
}
|
||||
@ -1196,6 +1210,7 @@ function SplitView({
|
||||
projectId,
|
||||
workState,
|
||||
refreshWorkState,
|
||||
refitSignal,
|
||||
onOpenConversation,
|
||||
onOpenPluginsSettings,
|
||||
}: SplitViewProps) {
|
||||
@ -1241,6 +1256,7 @@ function SplitView({
|
||||
projectId={projectId}
|
||||
workState={workState}
|
||||
refreshWorkState={refreshWorkState}
|
||||
refitSignal={refitSignal}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onOpenPluginsSettings={onOpenPluginsSettings}
|
||||
parentSplit={{
|
||||
@ -1336,6 +1352,7 @@ interface GridViewProps {
|
||||
projectId: string;
|
||||
workState: ProjectWorkState | null;
|
||||
refreshWorkState: () => Promise<void>;
|
||||
refitSignal: number;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
onOpenPluginsSettings?: () => void;
|
||||
}
|
||||
@ -1347,6 +1364,7 @@ function GridView({
|
||||
projectId,
|
||||
workState,
|
||||
refreshWorkState,
|
||||
refitSignal,
|
||||
onOpenConversation,
|
||||
onOpenPluginsSettings,
|
||||
}: GridViewProps) {
|
||||
@ -1388,6 +1406,7 @@ function GridView({
|
||||
projectId={projectId}
|
||||
workState={workState}
|
||||
refreshWorkState={refreshWorkState}
|
||||
refitSignal={refitSignal}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onOpenPluginsSettings={onOpenPluginsSettings}
|
||||
/>
|
||||
|
||||
@ -11,14 +11,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Button, Spinner, cn } from "@/shared";
|
||||
import { TicketViewportSelect } from "./TicketViewportSelect";
|
||||
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 {
|
||||
projectId: string;
|
||||
ticketRef: string;
|
||||
@ -53,24 +48,25 @@ export function TicketAssistantPanel({
|
||||
{!open ? (
|
||||
// ── Session opener ──
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
<TicketViewportSelect
|
||||
aria-label="profil de l'assistant"
|
||||
className={selectClass}
|
||||
value={profileId}
|
||||
disabled={vm.opening || vm.profiles.length === 0}
|
||||
onChange={(e) => setProfileId(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{vm.profiles.length === 0
|
||||
? "Aucun profil disponible"
|
||||
: "Choisir un profil IA…"}
|
||||
</option>
|
||||
{vm.profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={[
|
||||
{
|
||||
value: "",
|
||||
label:
|
||||
vm.profiles.length === 0
|
||||
? "Aucun profil disponible"
|
||||
: "Choisir un profil IA…",
|
||||
},
|
||||
...vm.profiles.map((profile) => ({
|
||||
value: profile.id,
|
||||
label: profile.name,
|
||||
})),
|
||||
]}
|
||||
onChange={setProfileId}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!profileId || vm.opening}
|
||||
|
||||
@ -17,6 +17,7 @@ import { useTicketDetail } from "./useTicketDetail";
|
||||
import { useProjectAgents } from "./useProjectAgents";
|
||||
import { TicketAssistantPanel } from "./TicketAssistantPanel";
|
||||
import { TicketPicker } from "./TicketPicker";
|
||||
import { TicketViewportSelect } from "./TicketViewportSelect";
|
||||
import {
|
||||
PriorityBadge,
|
||||
StatusBadge,
|
||||
@ -30,12 +31,6 @@ import {
|
||||
statusLabel,
|
||||
} 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(
|
||||
"w-full rounded-md bg-raised p-3 text-sm text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
@ -320,44 +315,38 @@ export function TicketDetail({
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-muted">
|
||||
Status
|
||||
<select
|
||||
<TicketViewportSelect
|
||||
aria-label="ticket status"
|
||||
className={selectClass}
|
||||
value={t.status}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) =>
|
||||
options={TICKET_STATUSES.map((status) => ({
|
||||
value: status,
|
||||
label: statusLabel(status),
|
||||
}))}
|
||||
onChange={(next) =>
|
||||
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 className="flex items-center gap-2 text-xs text-muted">
|
||||
Priority
|
||||
<select
|
||||
<TicketViewportSelect
|
||||
aria-label="ticket priority"
|
||||
className={selectClass}
|
||||
value={t.priority}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) =>
|
||||
options={TICKET_PRIORITIES.map((priority) => ({
|
||||
value: priority,
|
||||
label: priorityLabel(priority),
|
||||
}))}
|
||||
onChange={(next) =>
|
||||
void vm.updateFields({
|
||||
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>
|
||||
</div>
|
||||
</Section>
|
||||
@ -426,19 +415,16 @@ export function TicketDetail({
|
||||
{/* Add a link via the TicketPicker popup (#17): the kind is chosen
|
||||
here, the target ticket is selected in the popup (no manual #id). */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
<TicketViewportSelect
|
||||
aria-label="link kind"
|
||||
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}>
|
||||
{linkKindLabel(k)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={TICKET_LINK_KINDS.map((kind) => ({
|
||||
value: kind,
|
||||
label: linkKindLabel(kind),
|
||||
}))}
|
||||
onChange={(next) => setLinkKind(next as TicketLinkKind)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label="add link"
|
||||
@ -479,24 +465,25 @@ export function TicketDetail({
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<select
|
||||
<TicketViewportSelect
|
||||
aria-label="assign agent"
|
||||
className={selectClass}
|
||||
value={assignPick}
|
||||
disabled={vm.busy || assignable.length === 0}
|
||||
onChange={(e) => setAssignPick(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{assignable.length === 0
|
||||
? "No more agents"
|
||||
: "Select an agent…"}
|
||||
</option>
|
||||
{assignable.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={[
|
||||
{
|
||||
value: "",
|
||||
label:
|
||||
assignable.length === 0
|
||||
? "No more agents"
|
||||
: "Select an agent…",
|
||||
},
|
||||
...assignable.map((agent) => ({
|
||||
value: agent.id,
|
||||
label: agent.name,
|
||||
})),
|
||||
]}
|
||||
onChange={setAssignPick}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!assignPick || vm.busy}
|
||||
|
||||
@ -15,7 +15,8 @@
|
||||
|
||||
import type { TicketPriority, TicketStatus } from "@/domain";
|
||||
import type { TicketListSort, TicketListSortField } from "@/ports";
|
||||
import { Button, Input, cn } from "@/shared";
|
||||
import { Button, Input } from "@/shared";
|
||||
import { TicketViewportSelect } from "./TicketViewportSelect";
|
||||
import {
|
||||
PriorityBadge,
|
||||
StatusBadge,
|
||||
@ -25,13 +26,6 @@ import {
|
||||
statusLabel,
|
||||
} 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). */
|
||||
const SORT_FIELD_LABEL: Record<TicketListSortField, string> = {
|
||||
number: "Numéro",
|
||||
@ -146,23 +140,22 @@ export function TicketFacetsBar({
|
||||
>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
Trier par
|
||||
<select
|
||||
<TicketViewportSelect
|
||||
aria-label="sort tickets by"
|
||||
className={selectClass}
|
||||
value={sort?.field ?? ""}
|
||||
onChange={(e) => {
|
||||
const field = e.target.value as TicketListSortField | "";
|
||||
options={[
|
||||
{ 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);
|
||||
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>
|
||||
{sort && (
|
||||
<Button
|
||||
|
||||
@ -145,7 +145,8 @@ describe("TicketPicker (#18)", () => {
|
||||
|
||||
// The sort control is present in the picker too (shared TicketFacetsBar).
|
||||
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(() =>
|
||||
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
|
||||
field: "title",
|
||||
|
||||
105
frontend/src/features/tickets/TicketViewportSelect.test.tsx
Normal file
105
frontend/src/features/tickets/TicketViewportSelect.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
192
frontend/src/features/tickets/TicketViewportSelect.tsx
Normal file
192
frontend/src/features/tickets/TicketViewportSelect.tsx
Normal 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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -10,12 +10,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
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 { useProjectAgents } from "./useProjectAgents";
|
||||
import { SprintManager } from "./SprintManager";
|
||||
import { SprintPicker } from "./SprintPicker";
|
||||
import { TicketFacetsBar } from "./TicketFacetsBar";
|
||||
import { TicketViewportSelect } from "./TicketViewportSelect";
|
||||
import {
|
||||
PriorityBadge,
|
||||
StatusBadge,
|
||||
@ -24,12 +25,6 @@ import {
|
||||
priorityLabel,
|
||||
} 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 {
|
||||
projectId: string;
|
||||
/** 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)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
<TicketViewportSelect
|
||||
aria-label="new ticket priority"
|
||||
className={selectClass}
|
||||
value={newPriority}
|
||||
onChange={(e) => setNewPriority(e.target.value as TicketPriority)}
|
||||
>
|
||||
{TICKET_PRIORITIES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{priorityLabel(p)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={TICKET_PRIORITIES.map((priority) => ({
|
||||
value: priority,
|
||||
label: priorityLabel(priority),
|
||||
}))}
|
||||
onChange={(next) => setNewPriority(next as TicketPriority)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
@ -211,24 +203,23 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
<TicketViewportSelect
|
||||
aria-label="filter by assignee"
|
||||
className={selectClass}
|
||||
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.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>
|
||||
|
||||
@ -363,21 +354,20 @@ function SprintSection({
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 pl-6">
|
||||
<select
|
||||
<TicketViewportSelect
|
||||
aria-label={`sprint for ${t.ref}`}
|
||||
className={selectClass}
|
||||
value={t.sprintId ?? ""}
|
||||
onChange={(e) =>
|
||||
onAssignSprint(t.ref, e.target.value || null)
|
||||
options={[
|
||||
{ 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>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@ -353,9 +353,8 @@ describe("TicketsView", () => {
|
||||
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined();
|
||||
|
||||
// Choose a field ⇒ ascending by default, relayed in the query.
|
||||
fireEvent.change(screen.getByLabelText("sort tickets by"), {
|
||||
target: { value: "title" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("sort tickets by"));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Titre" }));
|
||||
await waitFor(() =>
|
||||
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
|
||||
field: "title",
|
||||
@ -375,9 +374,8 @@ describe("TicketsView", () => {
|
||||
);
|
||||
|
||||
// Back to « Par défaut » ⇒ `sort` dropped from the query again.
|
||||
fireEvent.change(screen.getByLabelText("sort tickets by"), {
|
||||
target: { value: "" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("sort tickets by"));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Par défaut" }));
|
||||
await waitFor(() =>
|
||||
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined(),
|
||||
);
|
||||
@ -390,8 +388,8 @@ describe("TicketsView", () => {
|
||||
fireEvent.click(await screen.findByText("Editable"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
const statusSelect = within(dialog).getByLabelText("ticket status");
|
||||
fireEvent.change(statusSelect, { target: { value: "inProgress" } });
|
||||
fireEvent.click(within(dialog).getByLabelText("ticket status"));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "In progress" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
||||
@ -426,9 +424,8 @@ describe("TicketsView", () => {
|
||||
fireEvent.click(await screen.findByText("Assignable"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText("assign agent"), {
|
||||
target: { value: known.id },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByLabelText("assign agent"));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Backend" }));
|
||||
fireEvent.click(within(dialog).getByText("Assign"));
|
||||
|
||||
await waitFor(async () => {
|
||||
@ -495,9 +492,8 @@ describe("TicketsView", () => {
|
||||
|
||||
// Change the priority — an immediate-apply mutation that re-fetches the ticket
|
||||
// and bumps its version. The unsaved draft must survive.
|
||||
fireEvent.change(within(dialog).getByLabelText("ticket priority"), {
|
||||
target: { value: "high" },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByLabelText("ticket priority"));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "High" }));
|
||||
|
||||
// The backend applied the priority bump…
|
||||
await waitFor(async () => {
|
||||
@ -643,9 +639,8 @@ describe("TicketsView", () => {
|
||||
expect(within(bucket).getByText("Movable")).toBeTruthy();
|
||||
|
||||
// Pick the sprint in the row selector → assign it.
|
||||
fireEvent.change(screen.getByLabelText(`sprint for ${t.ref}`), {
|
||||
target: { value: "s1" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText(`sprint for ${t.ref}`));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Sprint One" }));
|
||||
|
||||
// The gateway recorded the membership…
|
||||
await waitFor(async () => {
|
||||
@ -777,7 +772,8 @@ describe("TicketsView", () => {
|
||||
);
|
||||
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"));
|
||||
|
||||
expect(
|
||||
@ -837,9 +833,8 @@ describe("TicketsView", () => {
|
||||
expect(within(detail).queryByLabelText("link target ref")).toBeNull();
|
||||
|
||||
// Choose the link kind, then open the picker and select the target ticket.
|
||||
fireEvent.change(within(detail).getByLabelText("link kind"), {
|
||||
target: { value: "blocks" },
|
||||
});
|
||||
fireEvent.click(within(detail).getByLabelText("link kind"));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "blocks" }));
|
||||
fireEvent.click(within(detail).getByLabelText("add link"));
|
||||
|
||||
// The picker excludes the ticket itself; the target is offered.
|
||||
|
||||
Reference in New Issue
Block a user