feat(frontend): ajoute les menus Panneaux/Paramètres à la version web (#90)
WebWorkspace gagne un menu « Panneaux » qui route la surface principale du projet ouvert vers les panneaux transport-neutres déjà réutilisables (contexte, agents, templates, skills, permissions, mémoire, git) en plus des surfaces web existantes (travail live, tickets, sprints), ainsi qu'un variant web du panneau Projets (création par chemin serveur saisi manuellement, sans browse natif). WebApp gagne un menu « Paramètres » (Profils IA, Appareils, Déploiement désactivé "Desktop uniquement") qui remplace l'ancien bouton unique "Appareils". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -1,38 +1,53 @@
|
||||
/**
|
||||
* Web client root — ticket #13, lot F2. Mounted (instead of the desktop `App`)
|
||||
* only when the HTTP transport is selected (`VITE_TRANSPORT="http"`).
|
||||
* Web client root — ticket #13, lot F2, extended by #90 (missing menus).
|
||||
* Mounted (instead of the desktop `App`) only when the HTTP transport is
|
||||
* selected (`VITE_TRANSPORT="http"`).
|
||||
*
|
||||
* Routes on the {@link WebSession} paired flag: not paired ⇒ {@link PairingScreen};
|
||||
* paired ⇒ the read-only {@link WebWorkspace}. It subscribes to the session's
|
||||
* `onUnauthorized` signal so a `401` from any `/api/invoke` (expired/missing
|
||||
* cookie) drops back to pairing automatically. "Se déconnecter" (F6) revokes the
|
||||
* server session (`POST /api/logout`), tears down the live WS singleton, and
|
||||
* returns to pairing.
|
||||
* paired ⇒ the read-only {@link WebWorkspace}, or a Settings section opened from
|
||||
* the "Paramètres" menu (#90). It subscribes to the session's `onUnauthorized`
|
||||
* signal so a `401` from any `/api/invoke` (expired/missing cookie) drops back to
|
||||
* pairing automatically. "Se déconnecter" (F6) revokes the server session
|
||||
* (`POST /api/logout`), tears down the live WS singleton, and returns to pairing.
|
||||
*
|
||||
* "Appareils" (#77) mounts the shared {@link DevicesScreen} — the same component
|
||||
* the desktop shows under `Paramètres → Appareils`. It is what makes a headless
|
||||
* install usable: generating a pairing code from an already-paired phone instead
|
||||
* of restarting the server with `--new-code`.
|
||||
* "Paramètres" (#90) replaces the former single "Appareils" header toggle with a
|
||||
* menu of sections: **Profils IA** (mounts the transport-neutral
|
||||
* {@link ProfilesSettings}), **Appareils** (#77, mounts the shared
|
||||
* {@link DevicesScreen} — what makes a headless install usable: generating a
|
||||
* pairing code from an already-paired phone instead of restarting the server with
|
||||
* `--new-code`), and **Déploiement**, disabled — the embedded server is
|
||||
* desktop-only (a web client is *served by* it, so it must not reconfigure or
|
||||
* stop it, see `adapters/http/unsupported.ts`).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/shared";
|
||||
import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http";
|
||||
import { DevicesScreen } from "@/features/devices";
|
||||
import { ProfilesSettings } from "@/features/first-run";
|
||||
import { PairingScreen } from "./PairingScreen";
|
||||
import { WebWorkspace } from "./WebWorkspace";
|
||||
import { WebMenuSheet } from "./WebMenuSheet";
|
||||
|
||||
interface WebAppProps {
|
||||
/** Injectable session (tests); defaults to the shared singleton. */
|
||||
session?: WebSession;
|
||||
}
|
||||
|
||||
type WebSettingsSection = "aiProfiles" | "devices";
|
||||
|
||||
const SETTINGS_SECTION_LABEL: Record<WebSettingsSection, string> = {
|
||||
aiProfiles: "Profils IA",
|
||||
devices: "Appareils",
|
||||
};
|
||||
|
||||
export function WebApp({ session }: WebAppProps = {}) {
|
||||
const webSession = session ?? getWebSession();
|
||||
const [paired, setPaired] = useState(() => webSession.isPaired());
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [showDevices, setShowDevices] = useState(false);
|
||||
const [settingsSection, setSettingsSection] = useState<WebSettingsSection | null>(null);
|
||||
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// A 401 anywhere clears the flag and fires this: return to pairing. The live
|
||||
@ -40,7 +55,8 @@ export function WebApp({ session }: WebAppProps = {}) {
|
||||
// path for a *suffered* revocation (#77): another device revoked us, the
|
||||
// server rejects the next call, and we land back on pairing.
|
||||
return webSession.onUnauthorized(() => {
|
||||
setShowDevices(false);
|
||||
setSettingsSection(null);
|
||||
setSettingsMenuOpen(false);
|
||||
setPaired(false);
|
||||
});
|
||||
}, [webSession]);
|
||||
@ -55,7 +71,8 @@ export function WebApp({ session }: WebAppProps = {}) {
|
||||
} finally {
|
||||
disconnectWebLive();
|
||||
setSigningOut(false);
|
||||
setShowDevices(false);
|
||||
setSettingsSection(null);
|
||||
setSettingsMenuOpen(false);
|
||||
setPaired(false);
|
||||
}
|
||||
}
|
||||
@ -69,7 +86,8 @@ export function WebApp({ session }: WebAppProps = {}) {
|
||||
const onSessionEnded = useCallback(() => {
|
||||
webSession.forget();
|
||||
disconnectWebLive();
|
||||
setShowDevices(false);
|
||||
setSettingsSection(null);
|
||||
setSettingsMenuOpen(false);
|
||||
setPaired(false);
|
||||
}, [webSession]);
|
||||
|
||||
@ -90,10 +108,10 @@ export function WebApp({ session }: WebAppProps = {}) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed={showDevices}
|
||||
onClick={() => setShowDevices((v) => !v)}
|
||||
aria-pressed={settingsMenuOpen}
|
||||
onClick={() => setSettingsMenuOpen(true)}
|
||||
>
|
||||
{showDevices ? "Projets" : "Appareils"}
|
||||
Paramètres
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" loading={signingOut} onClick={() => void signOut()}>
|
||||
Se déconnecter
|
||||
@ -105,14 +123,85 @@ export function WebApp({ session }: WebAppProps = {}) {
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{!paired ? (
|
||||
<PairingScreen session={webSession} onPaired={() => setPaired(true)} />
|
||||
) : showDevices ? (
|
||||
<div className="h-full overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))] pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:p-6">
|
||||
) : settingsSection === "aiProfiles" ? (
|
||||
<SettingsSectionScreen
|
||||
title={SETTINGS_SECTION_LABEL.aiProfiles}
|
||||
onBack={() => setSettingsSection(null)}
|
||||
>
|
||||
<ProfilesSettings />
|
||||
</SettingsSectionScreen>
|
||||
) : settingsSection === "devices" ? (
|
||||
<SettingsSectionScreen
|
||||
title={SETTINGS_SECTION_LABEL.devices}
|
||||
onBack={() => setSettingsSection(null)}
|
||||
>
|
||||
<DevicesScreen onSessionEnded={onSessionEnded} />
|
||||
</div>
|
||||
</SettingsSectionScreen>
|
||||
) : (
|
||||
<WebWorkspace />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{settingsMenuOpen && (
|
||||
<WebMenuSheet
|
||||
title="Paramètres"
|
||||
onClose={() => setSettingsMenuOpen(false)}
|
||||
entries={[
|
||||
{
|
||||
id: "aiProfiles",
|
||||
label: SETTINGS_SECTION_LABEL.aiProfiles,
|
||||
active: settingsSection === "aiProfiles",
|
||||
onSelect: () => {
|
||||
setSettingsSection("aiProfiles");
|
||||
setSettingsMenuOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "devices",
|
||||
label: SETTINGS_SECTION_LABEL.devices,
|
||||
active: settingsSection === "devices",
|
||||
onSelect: () => {
|
||||
setSettingsSection("devices");
|
||||
setSettingsMenuOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "deployment",
|
||||
label: "Déploiement",
|
||||
active: false,
|
||||
disabled: true,
|
||||
secondaryLabel: "Desktop uniquement",
|
||||
hint: "Desktop uniquement",
|
||||
onSelect: () => {},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A Settings section takes over the main area, with a "← Retour" affordance. */
|
||||
function SettingsSectionScreen({
|
||||
title,
|
||||
onBack,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
onBack: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2 pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))]">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
← Retour
|
||||
</Button>
|
||||
<span className="text-sm font-medium text-content">{title}</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))] pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:p-6">
|
||||
<div className="mx-auto w-full max-w-xl">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
101
frontend/src/features/web/WebMenuSheet.tsx
Normal file
101
frontend/src/features/web/WebMenuSheet.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Full-screen navigation sheet for the web client (ticket #90): a mobile-first
|
||||
* menu — never a desktop dropdown — reused by both the "Panneaux" menu
|
||||
* ({@link WebWorkspace}) and the "Paramètres" menu ({@link WebApp}). Same shell
|
||||
* as {@link WebTicketPickerSheet}: fixed header with a close action, an optional
|
||||
* shared note, and a scrollable list of entries. The active entry is marked with
|
||||
* "●"; a disabled entry stays visible (never removed) so its reason is
|
||||
* discoverable, via `hint` (title tooltip) and/or `secondaryLabel` (always-visible
|
||||
* text — the only way to communicate "why" on a touch device with no hover).
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { Button, cn, zIndex } from "@/shared";
|
||||
|
||||
export interface WebMenuEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
/** Tooltip shown on hover/focus (desktop browsers); not relied on for touch. */
|
||||
hint?: string;
|
||||
/** Always-visible secondary text, e.g. "Desktop uniquement". */
|
||||
secondaryLabel?: string;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export interface WebMenuSheetProps {
|
||||
title: string;
|
||||
entries: WebMenuEntry[];
|
||||
/** Shared note shown once above the list (e.g. why some entries are disabled). */
|
||||
note?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WebMenuSheet({ title, entries, note, onClose }: WebMenuSheetProps) {
|
||||
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]);
|
||||
|
||||
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>
|
||||
|
||||
{note && (
|
||||
<p className="shrink-0 border-b border-border px-4 py-2 text-xs text-muted">{note}</p>
|
||||
)}
|
||||
|
||||
<ul className="min-h-0 flex-1 overflow-auto px-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={entry.disabled}
|
||||
title={entry.hint}
|
||||
aria-current={entry.active ? "true" : undefined}
|
||||
onClick={() => {
|
||||
if (entry.disabled) return;
|
||||
entry.onSelect();
|
||||
}}
|
||||
className={cn(
|
||||
"flex min-h-[44px] w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||
"hover:bg-raised focus:bg-raised focus:outline-none disabled:opacity-50 disabled:hover:bg-transparent",
|
||||
entry.active && "bg-raised font-medium text-content",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true" className="w-3 shrink-0 text-primary">
|
||||
{entry.active ? "●" : ""}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
|
||||
{entry.secondaryLabel && (
|
||||
<span className="shrink-0 text-xs text-muted">{entry.secondaryLabel}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
frontend/src/features/web/WebMenus.test.tsx
Normal file
158
frontend/src/features/web/WebMenus.test.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Ticket #90 — the web "Panneaux" (WebWorkspace) and "Paramètres" (WebApp) menus.
|
||||
*
|
||||
* Both replace the previous ad-hoc navigation (a 3-tab tablist, a single
|
||||
* "Appareils" header toggle) with a full-screen {@link WebMenuSheet}: this pins
|
||||
* the new contract — entry list, active marker, disabled-with-reason state, and
|
||||
* that selecting an entry routes the main surface without ever crashing on a
|
||||
* desktop-only action.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { createMockGateways } from "@/adapters/mock";
|
||||
import { WebSession } from "@/adapters/http";
|
||||
import type { FetchLike } from "@/adapters/http/httpInvoker";
|
||||
import type { FlagStore } from "@/adapters/http/webSession";
|
||||
import { WebApp } from "./WebApp";
|
||||
|
||||
function memStore(): FlagStore {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => map.get(k) ?? null,
|
||||
setItem: (k, v) => void map.set(k, v),
|
||||
removeItem: (k) => void map.delete(k),
|
||||
};
|
||||
}
|
||||
|
||||
const okFetch: FetchLike = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true }),
|
||||
text: async () => "{}",
|
||||
});
|
||||
|
||||
async function seededGateways(): Promise<Gateways> {
|
||||
const gateways = createMockGateways();
|
||||
await gateways.project.createProject("Demo", "/srv/demo");
|
||||
return gateways;
|
||||
}
|
||||
|
||||
function renderPairedWebApp(gateways: Gateways) {
|
||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||
session.markPaired();
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<WebApp session={session} />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("WebWorkspace « Panneaux » menu (#90)", () => {
|
||||
it("only enables Projets while no project is open, with a help note", async () => {
|
||||
renderPairedWebApp(await seededGateways());
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Panneaux : Projets" }));
|
||||
expect(
|
||||
await screen.findByText("Ouvrir un projet pour utiliser ce panneau."),
|
||||
).toBeTruthy();
|
||||
|
||||
const contextEntry = screen.getByRole("button", { name: "Contexte projet" });
|
||||
expect(contextEntry).toHaveProperty("disabled", true);
|
||||
const projectsEntry = screen.getByRole("button", { name: "Projets" });
|
||||
expect(projectsEntry).toHaveProperty("disabled", false);
|
||||
|
||||
// A disabled entry does nothing.
|
||||
fireEvent.click(contextEntry);
|
||||
expect(screen.queryByText("Contexte projet")).toBeTruthy(); // sheet still open, menu entry still there
|
||||
});
|
||||
|
||||
it("routes the main surface to the selected panel once a project is open", async () => {
|
||||
renderPairedWebApp(await seededGateways());
|
||||
|
||||
fireEvent.click(await screen.findByText("Demo"));
|
||||
expect(await screen.findByRole("button", { name: "Panneaux : Travail" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Panneaux : Travail" }));
|
||||
const agentsEntry = await screen.findByRole("button", { name: "Agents" });
|
||||
expect(agentsEntry).toHaveProperty("disabled", false);
|
||||
fireEvent.click(agentsEntry);
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Panneaux : Agents" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes on Escape without changing the panel", async () => {
|
||||
renderPairedWebApp(await seededGateways());
|
||||
fireEvent.click(await screen.findByText("Demo"));
|
||||
await screen.findByRole("button", { name: "Panneaux : Travail" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Panneaux : Travail" }));
|
||||
await screen.findByRole("dialog", { name: "Panneaux" });
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
|
||||
expect(screen.queryByRole("dialog", { name: "Panneaux" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Panneaux : Travail" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebProjectsPanel — web project creation (#90)", () => {
|
||||
it("creates a project via a manual path and opens it", async () => {
|
||||
renderPairedWebApp(await seededGateways());
|
||||
|
||||
await screen.findByText("Demo");
|
||||
fireEvent.change(screen.getByLabelText("Nom du projet"), {
|
||||
target: { value: "Nouveau" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Chemin du projet"), {
|
||||
target: { value: "/srv/nouveau" },
|
||||
});
|
||||
expect(
|
||||
screen.getByText("Saisir un chemin absolu accessible depuis le serveur IdeA."),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Créer le projet" }));
|
||||
|
||||
// Creating opens it — routes straight to the Travail panel, like opening
|
||||
// an existing project from the list.
|
||||
expect(await screen.findByRole("button", { name: "Panneaux : Travail" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebApp « Paramètres » menu (#90)", () => {
|
||||
it("opens Profils IA and returns to the workspace via « ← Retour »", async () => {
|
||||
renderPairedWebApp(await seededGateways());
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Paramètres" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Profils IA" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Profils IA" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /^Panneaux :/ })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "← Retour" }));
|
||||
expect(await screen.findByRole("button", { name: /^Panneaux :/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens Appareils from the settings menu", async () => {
|
||||
renderPairedWebApp(await seededGateways());
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Paramètres" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Appareils" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "← Retour" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Déploiement as disabled, labelled desktop-only, and does nothing on click", async () => {
|
||||
renderPairedWebApp(await seededGateways());
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Paramètres" }));
|
||||
const deploymentEntry = await screen.findByRole("button", { name: /Déploiement/ });
|
||||
expect(deploymentEntry.textContent).toContain("Desktop uniquement");
|
||||
expect(deploymentEntry).toHaveProperty("disabled", true);
|
||||
|
||||
fireEvent.click(deploymentEntry);
|
||||
// Still on the menu — no navigation happened.
|
||||
expect(screen.getByRole("dialog", { name: "Paramètres" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -100,6 +100,19 @@ describe("web client on a phone viewport", () => {
|
||||
expect(screen.getByTestId("terminal-key-bar")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the Panneaux menu as a full-screen sheet at 360px (no fixed-width overflow)", async () => {
|
||||
await renderPairedWebApp();
|
||||
|
||||
fireEvent.click(await screen.findByText("Demo"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Panneaux : Travail" }));
|
||||
|
||||
const sheet = await screen.findByRole("dialog", { name: "Panneaux" });
|
||||
// Full-viewport overlay (`fixed inset-0`), not a fixed-width dropdown that
|
||||
// could overflow a 360px screen.
|
||||
expect(sheet.className).toContain("fixed");
|
||||
expect(sheet.className).toContain("inset-0");
|
||||
});
|
||||
|
||||
it("sizes the agent cell against the dynamic viewport, not a fixed 256px", async () => {
|
||||
await renderPairedWebApp();
|
||||
|
||||
|
||||
124
frontend/src/features/web/WebProjectsPanel.tsx
Normal file
124
frontend/src/features/web/WebProjectsPanel.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* `WebProjectsPanel` — the "Projects" panel, web variant (ticket #90).
|
||||
*
|
||||
* The desktop `ProjectsView` project manager depends on `SystemGateway.pickFolder`
|
||||
* (a native folder-browse dialog), which is desktop-only (see
|
||||
* `adapters/http/unsupported.ts`). The web client cannot reuse it as-is: project
|
||||
* creation here takes the root as a manually typed absolute path, resolved
|
||||
* server-side (the machine running the IdeA server, not the browsing device).
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { GatewayError, Project } from "@/domain";
|
||||
import { Button, Input, Panel, Spinner } from "@/shared";
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
export interface WebProjectsPanelProps {
|
||||
projects: Project[] | null;
|
||||
openId: string | null;
|
||||
onOpen: (projectId: string) => void;
|
||||
onCreate: (name: string, root: string) => Promise<Project>;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function WebProjectsPanel({
|
||||
projects,
|
||||
openId,
|
||||
onOpen,
|
||||
onCreate,
|
||||
onRefresh,
|
||||
}: WebProjectsPanelProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [root, setRoot] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const canCreate = name.trim().length > 0 && root.trim().length > 0 && !busy;
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canCreate) return;
|
||||
setBusy(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const created = await onCreate(name.trim(), root.trim());
|
||||
setName("");
|
||||
setRoot("");
|
||||
onOpen(created.id);
|
||||
} catch (e) {
|
||||
setCreateError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold tracking-tight">Projets</h2>
|
||||
<Button variant="ghost" size="sm" onClick={onRefresh}>
|
||||
Rafraîchir
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Panel title="Nouveau projet">
|
||||
<form onSubmit={(e) => void submit(e)} className="flex flex-col gap-2">
|
||||
<Input
|
||||
aria-label="Nom du projet"
|
||||
placeholder="Nom du projet"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Chemin du projet"
|
||||
placeholder="/chemin/absolu/du/projet"
|
||||
value={root}
|
||||
onChange={(e) => setRoot(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted">
|
||||
Saisir un chemin absolu accessible depuis le serveur IdeA.
|
||||
</p>
|
||||
{createError && (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{createError}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" variant="primary" disabled={!canCreate} loading={busy}>
|
||||
Créer le projet
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
{projects === null ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
|
||||
<Spinner size={12} /> Chargement des projets…
|
||||
</span>
|
||||
) : projects.length === 0 ? (
|
||||
<p className="text-sm text-muted">Aucun projet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2" data-testid="web-project-list">
|
||||
{projects.map((p) => (
|
||||
<li key={p.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(p.id)}
|
||||
aria-pressed={openId === p.id}
|
||||
className="flex w-full flex-col items-start rounded-md border border-border bg-raised px-3 py-2 text-left transition-colors hover:border-primary aria-pressed:border-primary"
|
||||
>
|
||||
<span className="text-sm font-medium text-content">{p.name}</span>
|
||||
<span className="text-xs text-faint">{p.root}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,18 +1,22 @@
|
||||
/**
|
||||
* Live web workspace — ticket #13, lots F2 (read-only) + F5 (live surfaces).
|
||||
* Live web workspace — ticket #13/#86, extended by #90 (missing menus).
|
||||
*
|
||||
* After pairing: list projects, open one read-only, and show its **live**
|
||||
* work-state — agents (live/idle/busy), background tasks (with cancel/retry) and
|
||||
* per-agent inbox — updated in real time. The live mechanism is the desktop's own
|
||||
* transport-neutral {@link useProjectWorkState} hook (refreshes the read-model on
|
||||
* relevant `event.domain` events pushed over the WS, B7); {@link useLiveReconnect}
|
||||
* re-synchronises after a WS outage. Background cancel/retry go through the
|
||||
* {@link WorkStateGateway} — writes via DI, no direct transport. A CLI agent can
|
||||
* be opened into a live cell (F4). No component touches `@tauri-apps/api`.
|
||||
* After pairing: list/create projects (web variant — no native folder browse,
|
||||
* see {@link WebProjectsPanel}), open one read-only, and route the main surface
|
||||
* through the **"Panneaux" menu** (#90) across the transport-neutral panels also
|
||||
* used by the desktop `ProjectsView` (context/tickets/sprints/agents/templates/
|
||||
* skills/permissions/memory/git/projects) plus the web-specific live work-state
|
||||
* view below. Selecting a panel replaces the main surface — never a floating
|
||||
* window or dock (desktop-only chrome, out of scope for web, per Architect).
|
||||
*
|
||||
* Read-only allowlist used (B4/B7): `list_projects`, `open_project`,
|
||||
* `get_project_work_state`, plus the background `cancel`/`retry` commands and the
|
||||
* `event.domain` live stream.
|
||||
* The live work-state ("Travail") panel keeps its own bespoke, mobile-adapted
|
||||
* implementation rather than the desktop `ProjectWorkStatePanel`: that component's
|
||||
* "open" affordance attaches into a `LayoutGrid` cell, which the web client never
|
||||
* mounts (#69 pins this — no `layout-*` grid on web). This panel's own "Ouvrir"
|
||||
* instead mounts {@link WebAgentCell}, a touch-drivable terminal cell (F4) — the
|
||||
* mobile equivalent, kept transport-neutral via the same {@link useProjectWorkState}
|
||||
* hook and {@link WorkStateGateway} writes (cancel/retry). {@link useLiveReconnect}
|
||||
* re-synchronises the read-model after a WS outage.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@ -25,12 +29,22 @@ import type {
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
||||
import { ProjectContextPanel } from "@/features/projects/ProjectContextPanel";
|
||||
import { AgentsPanel } from "@/features/agents";
|
||||
import { TemplatesPanel } from "@/features/templates";
|
||||
import { SkillsPanel } from "@/features/skills";
|
||||
import { PermissionsPanel } from "@/features/permissions";
|
||||
import { MemoryPanel } from "@/features/memory";
|
||||
import { EmbedderSettings } from "@/features/embedder";
|
||||
import { GitPanel } from "@/features/git";
|
||||
import { Button, Panel, Spinner, cn } from "@/shared";
|
||||
import { WebAgentCell } from "./WebAgentCell";
|
||||
import { useLiveReconnect } from "./useLiveReconnect";
|
||||
import { useLiveConnectionState } from "./useLiveConnectionState";
|
||||
import { WebTicketsView } from "./tickets/WebTicketsView";
|
||||
import { WebSprintsView } from "./tickets/WebSprintsView";
|
||||
import { WebProjectsPanel } from "./WebProjectsPanel";
|
||||
import { WebMenuSheet } from "./WebMenuSheet";
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
@ -39,11 +53,57 @@ function describe(e: unknown): string {
|
||||
return String(e);
|
||||
}
|
||||
|
||||
/** The panels reachable from the web "Panneaux" menu (#90). */
|
||||
export type WebProjectPanelId =
|
||||
| "context"
|
||||
| "work"
|
||||
| "tickets"
|
||||
| "sprints"
|
||||
| "agents"
|
||||
| "templates"
|
||||
| "skills"
|
||||
| "permissions"
|
||||
| "memory"
|
||||
| "git"
|
||||
| "projects";
|
||||
|
||||
/** Menu order + French labels (ticket #78 — the human UI defaults to French). */
|
||||
const WEB_PANEL_ORDER: WebProjectPanelId[] = [
|
||||
"context",
|
||||
"work",
|
||||
"tickets",
|
||||
"sprints",
|
||||
"agents",
|
||||
"templates",
|
||||
"skills",
|
||||
"permissions",
|
||||
"memory",
|
||||
"git",
|
||||
"projects",
|
||||
];
|
||||
|
||||
const WEB_PANEL_LABEL: Record<WebProjectPanelId, string> = {
|
||||
context: "Contexte projet",
|
||||
work: "Travail",
|
||||
tickets: "Tickets",
|
||||
sprints: "Sprints",
|
||||
agents: "Agents",
|
||||
templates: "Templates",
|
||||
skills: "Skills",
|
||||
permissions: "Permissions",
|
||||
memory: "Mémoire",
|
||||
git: "Git",
|
||||
projects: "Projets",
|
||||
};
|
||||
|
||||
export function WebWorkspace() {
|
||||
const { project } = useGateways();
|
||||
const [projects, setProjects] = useState<Project[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [panel, setPanel] = useState<WebProjectPanelId>("projects");
|
||||
// Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel.
|
||||
const [focusSprintId, setFocusSprintId] = useState<string | null>(null);
|
||||
|
||||
const openRoot = projects?.find((p) => p.id === openId)?.root ?? null;
|
||||
|
||||
@ -69,6 +129,7 @@ export function WebWorkspace() {
|
||||
// its work-state. No layout/PTY is mounted here.
|
||||
await project.openProject(projectId);
|
||||
setOpenId(projectId);
|
||||
setPanel("work");
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
}
|
||||
@ -76,18 +137,29 @@ export function WebWorkspace() {
|
||||
[project],
|
||||
);
|
||||
|
||||
const createProject = useCallback(
|
||||
async (name: string, root: string): Promise<Project> => {
|
||||
setError(null);
|
||||
try {
|
||||
const created = await project.createProject(name, root);
|
||||
await refresh();
|
||||
return created;
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[project, refresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="web-workspace"
|
||||
className="flex h-full flex-col gap-4 overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))] pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:p-6"
|
||||
>
|
||||
<ReconnectBanner />
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold tracking-tight">Projets</h2>
|
||||
<Button variant="ghost" size="sm" onClick={() => void refresh()}>
|
||||
Rafraîchir
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<WebPanelMenuButton activePanel={panel} hasProject={Boolean(openId)} onSelect={setPanel} />
|
||||
|
||||
{error && (
|
||||
<Panel className="border-danger/40">
|
||||
@ -95,134 +167,120 @@ export function WebWorkspace() {
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{projects === null ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
|
||||
<Spinner size={12} /> Chargement des projets…
|
||||
</span>
|
||||
) : projects.length === 0 ? (
|
||||
<p className="text-sm text-muted">Aucun projet.</p>
|
||||
{panel === "projects" ? (
|
||||
<WebProjectsPanel
|
||||
projects={projects}
|
||||
openId={openId}
|
||||
onOpen={(id) => void openReadOnly(id)}
|
||||
onCreate={createProject}
|
||||
onRefresh={() => void refresh()}
|
||||
/>
|
||||
) : !openId ? (
|
||||
<p className="text-sm text-muted">Ouvrir un projet pour utiliser ce panneau.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2" data-testid="web-project-list">
|
||||
{projects.map((p) => (
|
||||
<li key={p.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openReadOnly(p.id)}
|
||||
aria-pressed={openId === p.id}
|
||||
className="flex w-full flex-col items-start rounded-md border border-border bg-raised px-3 py-2 text-left transition-colors hover:border-primary aria-pressed:border-primary"
|
||||
>
|
||||
<span className="text-sm font-medium text-content">{p.name}</span>
|
||||
<span className="text-xs text-faint">{p.root}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{openId && (
|
||||
<ProjectTabs projectId={openId} root={openRoot} />
|
||||
<WebPanelBody
|
||||
panel={panel}
|
||||
projectId={openId}
|
||||
root={openRoot}
|
||||
focusSprintId={focusSprintId}
|
||||
onViewSprintTickets={(sprintId) => {
|
||||
setFocusSprintId(sprintId);
|
||||
setPanel("tickets");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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);
|
||||
|
||||
/** Compact trigger + full-screen sheet for the "Panneaux" menu (#90). */
|
||||
function WebPanelMenuButton({
|
||||
activePanel,
|
||||
hasProject,
|
||||
onSelect,
|
||||
}: {
|
||||
activePanel: WebProjectPanelId;
|
||||
hasProject: boolean;
|
||||
onSelect: (panel: WebProjectPanelId) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
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 className="flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setOpen(true)}>
|
||||
Panneaux : {WEB_PANEL_LABEL[activePanel]}
|
||||
</Button>
|
||||
{open && (
|
||||
<WebMenuSheet
|
||||
title="Panneaux"
|
||||
note={
|
||||
!hasProject ? "Ouvrir un projet pour utiliser ce panneau." : undefined
|
||||
}
|
||||
onClose={() => setOpen(false)}
|
||||
entries={WEB_PANEL_ORDER.map((id) => {
|
||||
const disabled = id !== "projects" && !hasProject;
|
||||
return {
|
||||
id,
|
||||
label: WEB_PANEL_LABEL[id],
|
||||
active: id === activePanel,
|
||||
disabled,
|
||||
hint: disabled ? "Ouvrir un projet pour utiliser ce panneau." : undefined,
|
||||
onSelect: () => {
|
||||
onSelect(id);
|
||||
setOpen(false);
|
||||
},
|
||||
};
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders the requested panel's body for the opened project. */
|
||||
function WebPanelBody({
|
||||
panel,
|
||||
projectId,
|
||||
root,
|
||||
focusSprintId,
|
||||
onViewSprintTickets,
|
||||
}: {
|
||||
panel: Exclude<WebProjectPanelId, "projects">;
|
||||
projectId: string;
|
||||
root: string | null;
|
||||
focusSprintId: string | null;
|
||||
onViewSprintTickets: (sprintId: string) => void;
|
||||
}) {
|
||||
switch (panel) {
|
||||
case "context":
|
||||
return <ProjectContextPanel projectId={projectId} />;
|
||||
case "work":
|
||||
return <LiveProjectPanel projectId={projectId} root={root} />;
|
||||
case "tickets":
|
||||
return <WebTicketsView projectId={projectId} focusSprintId={focusSprintId} />;
|
||||
case "sprints":
|
||||
return (
|
||||
<WebSprintsView projectId={projectId} onViewSprintTickets={onViewSprintTickets} />
|
||||
);
|
||||
case "agents":
|
||||
return <AgentsPanel projectId={projectId} projectRoot={root ?? ""} />;
|
||||
case "templates":
|
||||
return <TemplatesPanel projectId={projectId} />;
|
||||
case "skills":
|
||||
return <SkillsPanel projectId={projectId} />;
|
||||
case "permissions":
|
||||
return <PermissionsPanel projectId={projectId} />;
|
||||
case "memory":
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<MemoryPanel projectId={projectId} />
|
||||
<EmbedderSettings />
|
||||
</div>
|
||||
);
|
||||
case "git":
|
||||
return <GitPanel projectId={projectId} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@ -74,28 +74,28 @@ function renderWorkspace(gateways: Gateways) {
|
||||
);
|
||||
}
|
||||
|
||||
async function openProjectAndTab(gateways: Gateways, tabName: string) {
|
||||
/** Opens the panel named `panelLabel` ("Tickets"/"Sprints"/…) via the "Panneaux" menu. */
|
||||
async function openProjectAndTab(gateways: Gateways, panelLabel: string) {
|
||||
renderWorkspace(gateways);
|
||||
fireEvent.click(await screen.findByText("IdeA"));
|
||||
await screen.findByRole("tablist", { name: "Navigation du projet" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: tabName }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /^Panneaux :/ }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: panelLabel }));
|
||||
}
|
||||
|
||||
describe("WebWorkspace — project tabs (ticket #86)", () => {
|
||||
it("defaults to the Live tab and lets the user switch to Tickets/Sprints", async () => {
|
||||
it("defaults to the Travail panel and lets the user switch to Tickets/Sprints via Panneaux", 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");
|
||||
expect(await screen.findByRole("button", { name: "Panneaux : Travail" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Tickets" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Panneaux : Travail" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Tickets" }));
|
||||
expect(await screen.findByRole("heading", { name: "Tickets" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Sprints" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Panneaux : Tickets" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Sprints" }));
|
||||
expect(await screen.findByRole("heading", { name: "Sprints" })).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user