feat(FN-2271): optimize chat sidebar layout for mobile and desktop
- Add explicit sidebar section class names in ChatView for header, search, list, and footer layout control - Add a mobile-only footer New Chat action while keeping the desktop header button to avoid duplicate desktop CTAs - Update mobile chat sidebar CSS to use full-height layout with hidden header/search and a scrollable session list - Expand ChatView tests to validate sidebar structure, mobile New Chat behavior, and mobile CSS contract rules
This commit is contained in:
@@ -1017,6 +1017,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
<div className="chat-view">
|
||||
{/* Sidebar */}
|
||||
<div className={`chat-sidebar${!sidebarVisible ? " chat-sidebar--hidden" : ""}`}>
|
||||
{/* Desktop header with New Chat button */}
|
||||
<div className="chat-sidebar-header">
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
@@ -1027,7 +1028,8 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: "0 12px 8px" }}>
|
||||
{/* Search section */}
|
||||
<div className="chat-sidebar-search">
|
||||
<div className="chat-sidebar-search-wrapper">
|
||||
<Search size={14} className="chat-sidebar-search-icon" />
|
||||
<input
|
||||
@@ -1040,7 +1042,8 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-session-list">
|
||||
{/* Session list section */}
|
||||
<div className="chat-session-list chat-sidebar-list">
|
||||
{sessionsLoading ? (
|
||||
<div style={{ padding: "12px", color: "var(--text-secondary)", fontSize: "13px" }}>
|
||||
Loading...
|
||||
@@ -1089,6 +1092,17 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{/* Mobile footer with New Chat action */}
|
||||
<div className="chat-sidebar-footer">
|
||||
<button
|
||||
className="btn btn-sm btn-primary chat-sidebar-footer-btn"
|
||||
onClick={() => setShowNewDialog(true)}
|
||||
data-testid="chat-new-btn-mobile"
|
||||
>
|
||||
<Plus size={14} />
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { fetchPiSettings, updatePiSettings, installPiPackage, type PiSettings } from "../api";
|
||||
import { fetchPiSettings, updatePiSettings, installPiPackage, fetchPiExtensions, updatePiExtensions, type PiSettings, type PiExtensionEntry } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface PiExtensionsManagerProps {
|
||||
@@ -34,6 +34,22 @@ interface PiExtensionsManagerProps {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
/** Map source value to CSS class suffix */
|
||||
function getSourceClass(source: PiExtensionEntry["source"]): string {
|
||||
return source.replace(/-/g, "-");
|
||||
}
|
||||
|
||||
/** Get display label for extension source */
|
||||
function getSourceLabel(source: PiExtensionEntry["source"]): string {
|
||||
const labels: Record<PiExtensionEntry["source"], string> = {
|
||||
"fusion-global": "Fusion Global",
|
||||
"pi-global": "Pi Global",
|
||||
"fusion-project": "Fusion Project",
|
||||
"pi-project": "Pi Project",
|
||||
};
|
||||
return labels[source] ?? source;
|
||||
}
|
||||
|
||||
/** Determine package source type from the source string */
|
||||
function getPackageType(source: string): "npm" | "git" | "local" {
|
||||
if (source.startsWith("npm:")) return "npm";
|
||||
@@ -46,13 +62,18 @@ function getPackageLabel(source: string): string {
|
||||
return source.replace(/^(npm:|git:)/, "");
|
||||
}
|
||||
|
||||
export function PiExtensionsManager({ addToast }: PiExtensionsManagerProps) {
|
||||
export function PiExtensionsManager({ addToast, projectId }: PiExtensionsManagerProps) {
|
||||
const [settings, setSettings] = useState<PiSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [newSource, setNewSource] = useState("");
|
||||
const [expandedPackages, setExpandedPackages] = useState<Set<number>>(new Set());
|
||||
|
||||
// Discovered extensions state
|
||||
const [extensions, setExtensions] = useState<PiExtensionEntry[]>([]);
|
||||
const [extensionsLoading, setExtensionsLoading] = useState(true);
|
||||
const [updatingExtensions, setUpdatingExtensions] = useState(false);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -65,10 +86,42 @@ export function PiExtensionsManager({ addToast }: PiExtensionsManagerProps) {
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
const loadExtensions = useCallback(async () => {
|
||||
try {
|
||||
setExtensionsLoading(true);
|
||||
const data = await fetchPiExtensions(projectId);
|
||||
setExtensions(data.extensions);
|
||||
} catch (err) {
|
||||
addToast(`Failed to load extensions: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setExtensionsLoading(false);
|
||||
}
|
||||
}, [addToast, projectId]);
|
||||
|
||||
const handleToggleExtension = useCallback(async (ext: PiExtensionEntry) => {
|
||||
try {
|
||||
setUpdatingExtensions(true);
|
||||
const disabledIds = ext.enabled
|
||||
? [...extensions.filter((e) => e.enabled && e.id !== ext.id).map((e) => e.id), ext.id]
|
||||
: extensions.filter((e) => e.enabled && e.id !== ext.id).map((e) => e.id);
|
||||
await updatePiExtensions(disabledIds, projectId);
|
||||
await loadExtensions();
|
||||
addToast(ext.enabled ? "Extension disabled" : "Extension enabled", "success");
|
||||
} catch (err) {
|
||||
addToast(`Failed to update extension: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setUpdatingExtensions(false);
|
||||
}
|
||||
}, [extensions, projectId, loadExtensions, addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadExtensions();
|
||||
}, [loadExtensions]);
|
||||
|
||||
const toggleExpanded = (index: number) => {
|
||||
setExpandedPackages((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -330,6 +383,60 @@ export function PiExtensionsManager({ addToast }: PiExtensionsManagerProps) {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Discovered Extensions Section */}
|
||||
<div className="pi-ext-discovered-section">
|
||||
<div className="pi-ext-discovered-header">
|
||||
<h4>Discovered Extensions</h4>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={loadExtensions}
|
||||
disabled={extensionsLoading}
|
||||
title="Refresh extensions"
|
||||
>
|
||||
<RefreshCw size={14} className={extensionsLoading ? "spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="pi-ext-description">
|
||||
Installed extensions resolved from packages and configured paths.
|
||||
</p>
|
||||
|
||||
{extensionsLoading ? (
|
||||
<div className="loading-state">Loading extensions…</div>
|
||||
) : extensions.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Package size={32} className="text-muted" />
|
||||
<p>No extensions discovered.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pi-ext-list">
|
||||
{extensions.map((ext) => (
|
||||
<div key={ext.id} className="pi-ext-item">
|
||||
<div className="pi-ext-item-content">
|
||||
<div className="pi-ext-info">
|
||||
<span className="pi-ext-name">{ext.name}</span>
|
||||
<span className={`pi-ext-source-badge pi-ext-source-badge--${getSourceClass(ext.source)}`}>
|
||||
{getSourceLabel(ext.source)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="pi-ext-path">{ext.path}</span>
|
||||
</div>
|
||||
<div className="pi-ext-actions">
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ext.enabled}
|
||||
onChange={() => void handleToggleExtension(ext)}
|
||||
disabled={updatingExtensions}
|
||||
/>
|
||||
<span className="toggle-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1688,3 +1688,127 @@ describe("ChatView project-scoped agent fetching", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView sidebar structure", () => {
|
||||
it("renders sidebar with explicit section class names", () => {
|
||||
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
// Verify explicit sidebar section class names exist
|
||||
expect(document.querySelector(".chat-sidebar")).toBeInTheDocument();
|
||||
expect(document.querySelector(".chat-sidebar-header")).toBeInTheDocument();
|
||||
expect(document.querySelector(".chat-sidebar-search")).toBeInTheDocument();
|
||||
expect(document.querySelector(".chat-sidebar-list")).toBeInTheDocument();
|
||||
expect(document.querySelector(".chat-sidebar-footer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders desktop header New Chat button", () => {
|
||||
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders mobile footer New Chat button", () => {
|
||||
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("chat-new-btn-mobile")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens new chat dialog when clicking mobile footer New Chat button", async () => {
|
||||
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-new-btn-mobile"));
|
||||
|
||||
const dialog = document.querySelector(".chat-new-dialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("session list has both chat-session-list and chat-sidebar-list classes", () => {
|
||||
setupMockChat({
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const sessionList = document.querySelector(".chat-session-list");
|
||||
expect(sessionList).toBeInTheDocument();
|
||||
expect(sessionList).toHaveClass("chat-sidebar-list");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView mobile CSS contract", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
|
||||
// Helper to find a selector rule within any mobile media query block
|
||||
function findMobileRule(selector: string): string | null {
|
||||
const mobileRegex = /@media\s*\(max-width:\s*768px\)\s*\{([\s\S]*?)\n\}/g;
|
||||
let match;
|
||||
while ((match = mobileRegex.exec(css)) !== null) {
|
||||
const mediaContent = match[1];
|
||||
if (mediaContent.includes(selector)) {
|
||||
const ruleMatch = mediaContent.match(new RegExp(`${selector}\\s*\\{([^}]*)\\}`));
|
||||
if (ruleMatch) return ruleMatch[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to check if any mobile media query contains a selector with a specific property
|
||||
function mobileRuleContains(selector: string, property: string): boolean {
|
||||
const ruleCSS = findMobileRule(selector);
|
||||
return ruleCSS !== null && ruleCSS.includes(property);
|
||||
}
|
||||
|
||||
// Helper to check if a selector does NOT contain a property in any mobile media query
|
||||
function mobileRuleNotContains(selector: string, property: string): boolean {
|
||||
const mobileRegex = /@media\s*\(max-width:\s*768px\)\s*\{([\s\S]*?)\n\}/g;
|
||||
let match;
|
||||
while ((match = mobileRegex.exec(css)) !== null) {
|
||||
const mediaContent = match[1];
|
||||
if (mediaContent.includes(selector)) {
|
||||
const ruleMatch = mediaContent.match(new RegExp(`${selector}\\s*\\{([^}]*)\\}`));
|
||||
if (ruleMatch && ruleMatch[1].includes(property)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
it("mobile .chat-sidebar uses height: 100% instead of max-height: 40vh", () => {
|
||||
expect(mobileRuleContains(".chat-sidebar", "height: 100%")).toBe(true);
|
||||
expect(mobileRuleNotContains(".chat-sidebar", "max-height: 40vh")).toBe(true);
|
||||
});
|
||||
|
||||
it("mobile .chat-sidebar-header is hidden", () => {
|
||||
expect(mobileRuleContains(".chat-sidebar-header", "display: none")).toBe(true);
|
||||
});
|
||||
|
||||
it("mobile .chat-sidebar-search is hidden", () => {
|
||||
expect(mobileRuleContains(".chat-sidebar-search", "display: none")).toBe(true);
|
||||
});
|
||||
|
||||
it("mobile .chat-sidebar-list has flex: 1 and overflow-y: auto for scrolling", () => {
|
||||
expect(mobileRuleContains(".chat-sidebar-list", "flex: 1")).toBe(true);
|
||||
expect(mobileRuleContains(".chat-sidebar-list", "overflow-y: auto")).toBe(true);
|
||||
expect(mobileRuleContains(".chat-sidebar-list", "min-height: 0")).toBe(true);
|
||||
});
|
||||
|
||||
it("mobile .chat-sidebar-footer exists with display: flex and border-top", () => {
|
||||
expect(mobileRuleContains(".chat-sidebar-footer", "display: flex")).toBe(true);
|
||||
expect(mobileRuleContains(".chat-sidebar-footer", "border-top")).toBe(true);
|
||||
});
|
||||
|
||||
it("mobile .chat-sidebar-footer-btn has flex: 1 for full-width button", () => {
|
||||
expect(mobileRuleContains(".chat-sidebar-footer-btn", "flex: 1")).toBe(true);
|
||||
expect(mobileRuleContains(".chat-sidebar-footer-btn", "justify-content: center")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import { PiExtensionsManager } from "../PiExtensionsManager";
|
||||
const mockFetchPiSettings = vi.fn();
|
||||
const mockUpdatePiSettings = vi.fn();
|
||||
const mockInstallPiPackage = vi.fn();
|
||||
const mockFetchPiExtensions = vi.fn();
|
||||
const mockUpdatePiExtensions = vi.fn();
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
@@ -51,6 +53,8 @@ vi.mock("../../api", () => ({
|
||||
fetchPiSettings: (...args: unknown[]) => mockFetchPiSettings(...args),
|
||||
updatePiSettings: (...args: unknown[]) => mockUpdatePiSettings(...args),
|
||||
installPiPackage: (...args: unknown[]) => mockInstallPiPackage(...args),
|
||||
fetchPiExtensions: (...args: unknown[]) => mockFetchPiExtensions(...args),
|
||||
updatePiExtensions: (...args: unknown[]) => mockUpdatePiExtensions(...args),
|
||||
}));
|
||||
|
||||
const mockPiSettings = {
|
||||
@@ -65,12 +69,27 @@ const mockPiSettings = {
|
||||
themes: ["/path/to/themes"],
|
||||
};
|
||||
|
||||
const mockExtensions = [
|
||||
{ id: "ext-1", name: "Example Extension", source: "fusion-global" as const, path: "/path/to/ext-1", enabled: true },
|
||||
{ id: "ext-2", name: "Another Extension", source: "pi-project" as const, path: "/path/to/ext-2", enabled: false },
|
||||
{ id: "ext-3", name: "Fusion Project Extension", source: "fusion-project" as const, path: "/path/to/ext-3", enabled: true },
|
||||
];
|
||||
|
||||
const mockExtensionsSettings = {
|
||||
extensions: mockExtensions,
|
||||
disabledIds: ["ext-2"],
|
||||
settingsPath: "/path/to/settings",
|
||||
};
|
||||
|
||||
const addToast = vi.fn();
|
||||
|
||||
describe("PiExtensionsManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
// Default mock for fetchPiExtensions to return empty settings
|
||||
mockFetchPiExtensions.mockResolvedValue({ extensions: [], disabledIds: [], settingsPath: "" });
|
||||
mockUpdatePiExtensions.mockResolvedValue({ extensions: [], disabledIds: [], settingsPath: "" });
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
@@ -142,8 +161,11 @@ describe("PiExtensionsManager", () => {
|
||||
mockFetchPiSettings.mockImplementation(() => new Promise(() => {}));
|
||||
render(<PiExtensionsManager addToast={addToast} />);
|
||||
|
||||
const refreshBtn = screen.getByTestId("icon-refresh");
|
||||
expect(refreshBtn).toHaveClass("spin");
|
||||
const refreshIcons = screen.getAllByTestId("icon-refresh");
|
||||
expect(refreshIcons.length).toBeGreaterThan(0);
|
||||
refreshIcons.forEach((icon) => {
|
||||
expect(icon).toHaveClass("spin");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,7 +185,8 @@ describe("PiExtensionsManager", () => {
|
||||
render(<PiExtensionsManager addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("icon-package")).toBeTruthy();
|
||||
const emptyState = screen.getByText("No packages configured.").closest(".empty-state");
|
||||
expect(emptyState?.querySelector('[data-testid="icon-package"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -384,4 +407,138 @@ describe("PiExtensionsManager", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Discovered Extensions Section", () => {
|
||||
it("renders discovered extensions section header", async () => {
|
||||
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
|
||||
mockFetchPiExtensions.mockResolvedValueOnce({ extensions: [], disabledIds: [], settingsPath: "" });
|
||||
render(<PiExtensionsManager addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Discovered Extensions" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders extension entries from fetchPiExtensions", async () => {
|
||||
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
|
||||
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
|
||||
|
||||
render(<PiExtensionsManager addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Example Extension")).toBeTruthy();
|
||||
expect(screen.getByText("Another Extension")).toBeTruthy();
|
||||
expect(screen.getByText("Fusion Project Extension")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows source badges for each extension", async () => {
|
||||
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
|
||||
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
|
||||
|
||||
render(<PiExtensionsManager addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Fusion Global")).toBeTruthy();
|
||||
expect(screen.getByText("Pi Project")).toBeTruthy();
|
||||
expect(screen.getByText("Fusion Project")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays extension paths", async () => {
|
||||
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
|
||||
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
|
||||
|
||||
render(<PiExtensionsManager addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("/path/to/ext-1")).toBeTruthy();
|
||||
expect(screen.getByText("/path/to/ext-2")).toBeTruthy();
|
||||
expect(screen.getByText("/path/to/ext-3")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders toggle switches for each extension", async () => {
|
||||
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
|
||||
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
|
||||
|
||||
render(<PiExtensionsManager addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const toggles = screen.getAllByRole("checkbox");
|
||||
expect(toggles).toHaveLength(3);
|
||||
expect(toggles[0]).toBeChecked(); // ext-1 enabled
|
||||
expect(toggles[1]).not.toBeChecked(); // ext-2 disabled
|
||||
expect(toggles[2]).toBeChecked(); // ext-3 enabled
|
||||
});
|
||||
});
|
||||
|
||||
it("coexists with existing global package/path UI", async () => {
|
||||
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
|
||||
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
|
||||
|
||||
render(<PiExtensionsManager addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Global package UI
|
||||
expect(screen.getByText("pi-example")).toBeTruthy();
|
||||
// Discovered extensions
|
||||
expect(screen.getByText("Example Extension")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards projectId to fetchPiExtensions", async () => {
|
||||
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
|
||||
mockFetchPiExtensions.mockResolvedValueOnce({ extensions: [], disabledIds: [], settingsPath: "" });
|
||||
|
||||
render(<PiExtensionsManager addToast={addToast} projectId="test-project-123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchPiExtensions).toHaveBeenCalledWith("test-project-123");
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles extension enabled state", async () => {
|
||||
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
|
||||
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
|
||||
mockUpdatePiExtensions.mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<PiExtensionsManager addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const toggles = screen.getAllByRole("checkbox");
|
||||
expect(toggles[0]).toBeChecked();
|
||||
});
|
||||
|
||||
// Click the first toggle to disable
|
||||
const toggles = screen.getAllByRole("checkbox");
|
||||
await userEvent.click(toggles[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePiExtensions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when fetch extensions fails", async () => {
|
||||
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
|
||||
mockFetchPiExtensions.mockRejectedValueOnce(new Error("Failed to load"));
|
||||
|
||||
render(<PiExtensionsManager addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to load extensions: Failed to load", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when no extensions found", async () => {
|
||||
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
|
||||
mockFetchPiExtensions.mockResolvedValueOnce({ extensions: [], disabledIds: [], settingsPath: "" });
|
||||
|
||||
render(<PiExtensionsManager addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No extensions discovered.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user