feat(frontend): SessionMenu kebab + NavSessions prop-driven; AppShell owns sessions query
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,9 @@ import { SteerInput, ComposerFooter } from "./SteerInput";
|
|||||||
import { WorkflowBar } from "./WorkflowBar";
|
import { WorkflowBar } from "./WorkflowBar";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
import { closeSession } from "../api/sessions";
|
import { closeSession, listSessions, resumeSession } from "../api/sessions";
|
||||||
|
import type { SessionSummary } from "../api/types";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,6 +20,9 @@ import { useRef, useState } from "react";
|
|||||||
*/
|
*/
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||||
|
const { data: sessions = [] } = useQuery<SessionSummary[]>({
|
||||||
|
queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000,
|
||||||
|
});
|
||||||
const composerRef = useRef<HTMLInputElement>(null);
|
const composerRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// The harness "holds the ball" whenever a session is live and no widget is
|
// The harness "holds the ball" whenever a session is live and no widget is
|
||||||
@@ -121,8 +126,10 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto px-2 pb-4">
|
<div className="flex-1 overflow-y-auto px-2 pb-4">
|
||||||
<NavSessions
|
<NavSessions
|
||||||
|
sessions={sessions}
|
||||||
activeSessionId={activeSessionId}
|
activeSessionId={activeSessionId}
|
||||||
onSessionSelect={setActiveSessionId}
|
onOpenPanel={async (id) => { await resumeSession(id); setActiveSessionId(id); }}
|
||||||
|
menuFor={() => null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,81 +1,27 @@
|
|||||||
// frontend/src/shell/NavSessions.test.tsx
|
import { render, screen } from "@testing-library/react";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { http, HttpResponse } from "msw";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { server } from "../test/msw";
|
|
||||||
import { NavSessions } from "./NavSessions";
|
import { NavSessions } from "./NavSessions";
|
||||||
|
import type { SessionSummary } from "../api/types";
|
||||||
|
|
||||||
function makeClient() {
|
const SESSIONS: SessionSummary[] = [
|
||||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
{ id: "s1", status: "open", question: "Come va?", summary: null, created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: false },
|
||||||
}
|
{ id: "s2", status: "closed", question: "Seconda domanda", summary: "r", created_at: "2026-01-02T00:00:00Z", updated_at: null, author: null, name: "Etichetta", group: null, archived: false },
|
||||||
|
|
||||||
function renderWithClient(ui: React.ReactElement) {
|
|
||||||
const client = makeClient();
|
|
||||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SESSION_LIST = [
|
|
||||||
{
|
|
||||||
id: "s1",
|
|
||||||
status: "open",
|
|
||||||
question: "Come va?",
|
|
||||||
summary: null,
|
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
|
||||||
updated_at: null,
|
|
||||||
author: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "s2",
|
|
||||||
status: "closed",
|
|
||||||
question: "Seconda domanda",
|
|
||||||
summary: "riassunto",
|
|
||||||
created_at: "2026-01-02T00:00:00Z",
|
|
||||||
updated_at: null,
|
|
||||||
author: null,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
test("lists sessions and shows name when present", () => {
|
||||||
server.use(
|
render(<NavSessions sessions={SESSIONS} activeSessionId={null} onOpenPanel={vi.fn()} menuFor={() => null} />);
|
||||||
http.get("http://localhost:8787/sessions", () =>
|
expect(screen.getByText("Come va?")).toBeInTheDocument();
|
||||||
HttpResponse.json(SESSION_LIST),
|
expect(screen.getByText("Etichetta")).toBeInTheDocument(); // name overrides question
|
||||||
),
|
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", () =>
|
|
||||||
new HttpResponse(null, { status: 204 }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lists sessions loaded from listSessions", async () => {
|
test("clicking a row opens the panel (does not resume)", async () => {
|
||||||
const onSelect = vi.fn();
|
const onOpenPanel = vi.fn();
|
||||||
renderWithClient(<NavSessions onSessionSelect={onSelect} activeSessionId={null} />);
|
render(<NavSessions sessions={SESSIONS} activeSessionId={null} onOpenPanel={onOpenPanel} menuFor={() => null} />);
|
||||||
await screen.findByText(/Come va\?/);
|
await userEvent.click(screen.getByText("Come va?"));
|
||||||
expect(screen.getByText(/Seconda domanda/)).toBeInTheDocument();
|
expect(onOpenPanel).toHaveBeenCalledWith("s1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking a session calls resumeSession and onSessionSelect", async () => {
|
test("active session is highlighted", () => {
|
||||||
const onSelect = vi.fn();
|
render(<NavSessions sessions={SESSIONS} activeSessionId="s2" onOpenPanel={vi.fn()} menuFor={() => null} />);
|
||||||
let resumeCalledWith: string | null = null;
|
expect(screen.getByTestId("session-item-s2")).toHaveAttribute("data-active", "true");
|
||||||
server.use(
|
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
|
|
||||||
resumeCalledWith = params.id as string;
|
|
||||||
return new HttpResponse(null, { status: 204 });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
renderWithClient(<NavSessions onSessionSelect={onSelect} activeSessionId={null} />);
|
|
||||||
await screen.findByText(/Come va\?/);
|
|
||||||
await userEvent.click(screen.getByText(/Come va\?/));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(resumeCalledWith).toBe("s1");
|
|
||||||
expect(onSelect).toHaveBeenCalledWith("s1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("active session is visually highlighted", async () => {
|
|
||||||
const onSelect = vi.fn();
|
|
||||||
renderWithClient(<NavSessions onSessionSelect={onSelect} activeSessionId="s2" />);
|
|
||||||
await screen.findByText(/Seconda domanda/);
|
|
||||||
const activeItem = screen.getByTestId("session-item-s2");
|
|
||||||
expect(activeItem).toHaveAttribute("data-active", "true");
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,15 @@
|
|||||||
// frontend/src/shell/NavSessions.tsx
|
// frontend/src/shell/NavSessions.tsx
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import type { ReactNode } from "react";
|
||||||
import { listSessions } from "../api/sessions";
|
|
||||||
import { resumeSession } from "../api/sessions";
|
|
||||||
import type { SessionSummary } from "../api/types";
|
import type { SessionSummary } from "../api/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
sessions: SessionSummary[];
|
||||||
activeSessionId: string | null;
|
activeSessionId: string | null;
|
||||||
onSessionSelect: (id: string) => void;
|
onOpenPanel: (id: string) => void;
|
||||||
|
menuFor: (session: SessionSummary) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavSessions({ activeSessionId, onSessionSelect }: Props) {
|
export function NavSessions({ sessions, activeSessionId, onOpenPanel, menuFor }: Props) {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { data: sessions = [] } = useQuery<SessionSummary[]>({
|
|
||||||
queryKey: ["sessions"],
|
|
||||||
queryFn: listSessions,
|
|
||||||
refetchInterval: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleClick(id: string) {
|
|
||||||
await resumeSession(id);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
|
||||||
onSessionSelect(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
|
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
|
||||||
@@ -30,48 +17,46 @@ export function NavSessions({ activeSessionId, onSessionSelect }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="flex flex-col gap-0.5">
|
<ul className="flex flex-col gap-0.5">
|
||||||
{sessions.map((s) => {
|
{sessions.map((s) => {
|
||||||
const active = s.id === activeSessionId;
|
const active = s.id === activeSessionId;
|
||||||
return (
|
return (
|
||||||
<li key={s.id}>
|
<li key={s.id}>
|
||||||
<button
|
<div
|
||||||
data-testid={`session-item-${s.id}`}
|
data-testid={`session-item-${s.id}`}
|
||||||
data-active={active ? "true" : "false"}
|
data-active={active ? "true" : "false"}
|
||||||
onClick={() => handleClick(s.id)}
|
onClick={() => onOpenPanel(s.id)}
|
||||||
className={[
|
className={[
|
||||||
"group w-full rounded-lg px-2.5 py-2 text-left transition-colors",
|
"group flex w-full cursor-pointer items-start gap-1 rounded-lg px-2.5 py-2 text-left transition-colors",
|
||||||
active
|
active ? "bg-[oklch(var(--primary)/0.12)]" : "hover:bg-accent",
|
||||||
? "bg-[oklch(var(--primary)/0.12)]"
|
|
||||||
: "hover:bg-accent",
|
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<span
|
<div className="min-w-0 flex-1">
|
||||||
className={[
|
|
||||||
"block truncate text-[0.8rem] leading-snug",
|
|
||||||
active ? "font-bold text-primary" : "text-foreground/90",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{s.question || s.id}
|
|
||||||
</span>
|
|
||||||
<span className="mt-1 flex items-center gap-1.5">
|
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
"size-1.5 rounded-full",
|
"block truncate text-[0.8rem] leading-snug",
|
||||||
s.status === "open"
|
active ? "font-bold text-primary" : "text-foreground/90",
|
||||||
? "bg-[oklch(var(--success))]"
|
|
||||||
: s.status === "finalized"
|
|
||||||
? "bg-primary"
|
|
||||||
: "bg-muted-foreground/50",
|
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
/>
|
>
|
||||||
<span className="text-[0.65rem] uppercase tracking-wide text-muted-foreground">
|
{s.name || s.question || s.id}
|
||||||
{s.status}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<span className="mt-1 flex items-center gap-1.5">
|
||||||
</button>
|
<span
|
||||||
|
className={[
|
||||||
|
"size-1.5 rounded-full",
|
||||||
|
s.status === "open"
|
||||||
|
? "bg-[oklch(var(--success))]"
|
||||||
|
: s.status === "finalized"
|
||||||
|
? "bg-primary"
|
||||||
|
: "bg-muted-foreground/50",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
<span className="text-[0.65rem] uppercase tracking-wide text-muted-foreground">{s.status}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{menuFor(s)}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Menu } from "@base-ui/react/menu";
|
||||||
|
import { MoreVertical } from "lucide-react";
|
||||||
|
import type { SessionSummary } from "../api/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
session: SessionSummary;
|
||||||
|
groups: string[];
|
||||||
|
onView: () => void;
|
||||||
|
onRename: () => void;
|
||||||
|
onMove: (group: string) => void;
|
||||||
|
onNewGroup: () => void;
|
||||||
|
onArchiveToggle: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemCls =
|
||||||
|
"flex w-full cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground";
|
||||||
|
|
||||||
|
export function SessionMenu({ session, groups, onView, onRename, onMove, onNewGroup, onArchiveToggle, onDelete }: Props) {
|
||||||
|
return (
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger
|
||||||
|
aria-label="Session actions"
|
||||||
|
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 data-popup-open:opacity-100"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreVertical className="size-4" />
|
||||||
|
</Menu.Trigger>
|
||||||
|
<Menu.Portal>
|
||||||
|
<Menu.Positioner side="bottom" align="end" sideOffset={4}>
|
||||||
|
<Menu.Popup className="z-50 min-w-44 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||||||
|
<Menu.Item className={itemCls} onClick={onView}>Vista divisa</Menu.Item>
|
||||||
|
<Menu.Item className={itemCls} onClick={onRename}>Rinomina</Menu.Item>
|
||||||
|
<Menu.SubmenuRoot>
|
||||||
|
<Menu.SubmenuTrigger className={itemCls}>Sposta nel gruppo ›</Menu.SubmenuTrigger>
|
||||||
|
<Menu.Portal>
|
||||||
|
<Menu.Positioner side="right" align="start">
|
||||||
|
<Menu.Popup className="z-50 min-w-44 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||||||
|
{groups.filter((g) => g !== session.group).map((g) => (
|
||||||
|
<Menu.Item key={g} className={itemCls} onClick={() => onMove(g)}>{g}</Menu.Item>
|
||||||
|
))}
|
||||||
|
{session.group && (
|
||||||
|
<Menu.Item className={itemCls} onClick={() => onMove("")}>Senza gruppo</Menu.Item>
|
||||||
|
)}
|
||||||
|
<Menu.Item className={itemCls} onClick={onNewGroup}>Nuovo gruppo…</Menu.Item>
|
||||||
|
</Menu.Popup>
|
||||||
|
</Menu.Positioner>
|
||||||
|
</Menu.Portal>
|
||||||
|
</Menu.SubmenuRoot>
|
||||||
|
<Menu.Item className={itemCls} onClick={onArchiveToggle}>
|
||||||
|
{session.archived ? "Ripristina" : "Archivia"}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Separator className="my-1 h-px bg-border" />
|
||||||
|
<Menu.Item className={`${itemCls} text-destructive`} onClick={onDelete}>Elimina</Menu.Item>
|
||||||
|
</Menu.Popup>
|
||||||
|
</Menu.Positioner>
|
||||||
|
</Menu.Portal>
|
||||||
|
</Menu.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user