Merge feature/ticket90-web-missing-menus into develop
Ajoute les menus Panneaux/Paramètres manquants à la version web (#90), avec élargissement de l'allowlist /api/invoke côté web-server pour router les commandes correspondantes. QA : 895/895 tests frontend, tsc propre, tests backend web_invoke_routes verts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -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