mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-07 19:20:34 +00:00
fix(ui): tidy the extension detail layout and narrow-screen overflow (#7936)
* fix(capabilities): tidy the extension detail layout and narrow-screen overflow The extension detail view led with a page-level reload button and sat the back button beside it 3px off: the two flowed inline and aligned by baseline, which the back button takes from its icon. It also kept a directory search that filters nothing there, a heading close to the page title, and status copy that read like body text. On narrow screens the tab strip widened the whole page, leaving a horizontal scrollbar. Lead with the back button and move reload to the opposite end, drop the search from the detail view, and give the detail an icon, package name and a status notice holding the installation guide. On the directory, reload now sits beside the hint that explains it. The tab strip scrolls inside itself and keeps the active tab in view, and reload collapses to an icon below `sm` with its accessible name intact. No copy changes. * fix(capabilities): realign the tab strip on resize without moving the page Review follow-up. The active tab was only realigned when the tab changed, so a rotation or window resize could leave it clipped. Realign whenever the strip resizes, and leave manual swipes alone. Scroll only the strip. scrollIntoView would also move the page, pulling it back up when a resize arrives after the user has scrolled past the strip. Also hide the strip's scrollbar in browsers without scrollbar-width support, and find the page scroller in the E2E test by computed style rather than by a class name.
This commit is contained in:
parent
c0b0ac3489
commit
3067b5a5bd
@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { BlocksIcon, SearchIcon, SparklesIcon } from "lucide-react";
|
||||
import { BlocksIcon, PuzzleIcon, SearchIcon, SparklesIcon } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@ -26,6 +26,20 @@ const subscribeHydration = () => () => undefined;
|
||||
const clientHydrated = () => true;
|
||||
const serverHydrated = () => false;
|
||||
|
||||
// Scrolls only the strip. scrollIntoView would also move the page, pulling it
|
||||
// back up if the strip resizes after the user has scrolled past it.
|
||||
function revealActiveTab(strip: HTMLElement) {
|
||||
const tab = strip.querySelector('[role="tab"][data-state="active"]');
|
||||
if (!tab) return;
|
||||
const tabBox = tab.getBoundingClientRect();
|
||||
const stripBox = strip.getBoundingClientRect();
|
||||
if (tabBox.left < stripBox.left) {
|
||||
strip.scrollLeft -= Math.ceil(stripBox.left - tabBox.left);
|
||||
} else if (tabBox.right > stripBox.right) {
|
||||
strip.scrollLeft += Math.ceil(tabBox.right - stripBox.right);
|
||||
}
|
||||
}
|
||||
|
||||
export function CapabilityCenter() {
|
||||
const hydrated = useSyncExternalStore(
|
||||
subscribeHydration,
|
||||
@ -43,7 +57,20 @@ export function CapabilityCenter() {
|
||||
: requestedTab === "extensions"
|
||||
? "extensions"
|
||||
: "plugins";
|
||||
// The detail view shows one extension, so there is no directory to filter.
|
||||
const showSearch = !(tab === "extensions" && params.get("extension"));
|
||||
const [query, setQuery] = useState("");
|
||||
// The tab strip scrolls on narrow screens. Keep the active tab visible when
|
||||
// the tab changes or the strip resizes, and leave manual swipes alone.
|
||||
const tabsRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const strip = tabsRef.current;
|
||||
if (!strip) return;
|
||||
// A new observation reports the current size at once, covering tab changes.
|
||||
const observer = new ResizeObserver(() => revealActiveTab(strip));
|
||||
observer.observe(strip);
|
||||
return () => observer.disconnect();
|
||||
}, [tab]);
|
||||
function changeTab(value: string) {
|
||||
setQuery("");
|
||||
router.replace(`${pathname}?tab=${value}`, { scroll: false });
|
||||
@ -67,31 +94,38 @@ export function CapabilityCenter() {
|
||||
{t.capabilities.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-full md:w-72">
|
||||
<SearchIcon className="text-muted-foreground pointer-events-none absolute top-3 left-3 size-4" />
|
||||
<Input
|
||||
disabled={!hydrated}
|
||||
className="bg-muted/30 h-10 rounded-xl pl-9 shadow-none"
|
||||
aria-label={
|
||||
tab === "extensions"
|
||||
? t.extensions.search
|
||||
: tab === "skills"
|
||||
? t.capabilities.searchSkills
|
||||
: t.capabilities.searchPlugins
|
||||
}
|
||||
placeholder={
|
||||
tab === "extensions"
|
||||
? t.extensions.search
|
||||
: tab === "skills"
|
||||
? t.capabilities.searchSkills
|
||||
: t.capabilities.searchPlugins
|
||||
}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{showSearch && (
|
||||
<div className="relative w-full md:w-80">
|
||||
<SearchIcon className="text-muted-foreground pointer-events-none absolute top-3 left-3 size-4" />
|
||||
<Input
|
||||
disabled={!hydrated}
|
||||
className="bg-muted/30 h-10 rounded-xl pl-9 shadow-none"
|
||||
aria-label={
|
||||
tab === "extensions"
|
||||
? t.extensions.search
|
||||
: tab === "skills"
|
||||
? t.capabilities.searchSkills
|
||||
: t.capabilities.searchPlugins
|
||||
}
|
||||
placeholder={
|
||||
tab === "extensions"
|
||||
? t.extensions.search
|
||||
: tab === "skills"
|
||||
? t.capabilities.searchSkills
|
||||
: t.capabilities.searchPlugins
|
||||
}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<Tabs value={tab} onValueChange={changeTab} className="mb-7 border-b">
|
||||
<Tabs
|
||||
ref={tabsRef}
|
||||
value={tab}
|
||||
onValueChange={changeTab}
|
||||
className="mb-7 overflow-x-auto border-b pb-px [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<TabsList variant="line" className="h-12 gap-7">
|
||||
<TabsTrigger value="plugins" className="gap-2 px-1 pb-4 text-sm">
|
||||
<BlocksIcon className="size-4" />
|
||||
@ -105,6 +139,7 @@ export function CapabilityCenter() {
|
||||
value="extensions"
|
||||
className="gap-2 px-1 pb-4 text-sm"
|
||||
>
|
||||
<PuzzleIcon className="size-4" />
|
||||
{t.extensions.title}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@ -1,16 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CircleAlertIcon,
|
||||
CircleCheckIcon,
|
||||
CircleMinusIcon,
|
||||
ExternalLinkIcon,
|
||||
InfoIcon,
|
||||
RefreshCwIcon,
|
||||
} from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { extensionDirectory } from "@/core/extensions/catalog";
|
||||
import {
|
||||
extensionDirectory,
|
||||
type ExtensionCatalogEntry,
|
||||
} from "@/core/extensions/catalog";
|
||||
import { useFrontendExtensions } from "@/core/extensions/hooks";
|
||||
import { extensionIcon } from "@/core/extensions/registry";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { Translations } from "@/core/i18n/locales/types";
|
||||
|
||||
import { PluginRow } from "./plugin-directory";
|
||||
|
||||
function ExtensionIconTile({ entry }: { entry: ExtensionCatalogEntry }) {
|
||||
const Icon = extensionIcon(entry.loaded?.extension?.icon ?? entry.icon);
|
||||
return (
|
||||
<div className="bg-muted flex size-12 shrink-0 items-center justify-center rounded-xl">
|
||||
<Icon className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtensionDetail({
|
||||
entry,
|
||||
copy,
|
||||
}: {
|
||||
entry: ExtensionCatalogEntry;
|
||||
copy: Translations["extensions"];
|
||||
}) {
|
||||
const { loaded } = entry;
|
||||
const notice = loaded?.error
|
||||
? { icon: <CircleAlertIcon />, text: copy.moduleUnavailable }
|
||||
: !loaded
|
||||
? { icon: <InfoIcon />, text: copy.catalogHint }
|
||||
: loaded.settings.enabled === true
|
||||
? { icon: <CircleCheckIcon />, text: copy.enabledManaged }
|
||||
: { icon: <CircleMinusIcon />, text: copy.disabledManaged };
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<ExtensionIconTile entry={entry} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-xl font-semibold tracking-tight">
|
||||
{entry.title}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1.5 text-sm leading-6">
|
||||
{entry.description}
|
||||
</p>
|
||||
{entry.package && (
|
||||
<p className="text-muted-foreground mt-2 font-mono text-xs break-all">
|
||||
{entry.package}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Alert
|
||||
role={loaded?.error ? "alert" : "note"}
|
||||
variant={loaded?.error ? "destructive" : "default"}
|
||||
className="[&>svg]:translate-y-2"
|
||||
>
|
||||
{notice.icon}
|
||||
<AlertDescription className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||||
<p className="min-w-0 grow basis-64 py-1">{notice.text}</p>
|
||||
{entry.guide && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-foreground"
|
||||
asChild
|
||||
>
|
||||
<a href={entry.guide} target="_blank" rel="noopener noreferrer">
|
||||
{copy.installationGuide}
|
||||
<ExternalLinkIcon />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExtensionGallery({ query = "" }: { query?: string }) {
|
||||
const { t } = useI18n();
|
||||
const publicQuery = useFrontendExtensions();
|
||||
@ -31,53 +114,52 @@ export function ExtensionGallery({ query = "" }: { query?: string }) {
|
||||
router.replace(`${pathname}?${next.toString()}`, { scroll: false });
|
||||
}
|
||||
const status = source.isPending ? (
|
||||
<p role="status">{t.extensions.loading}</p>
|
||||
<p role="status" className="text-muted-foreground text-sm">
|
||||
{t.extensions.loading}
|
||||
</p>
|
||||
) : source.isError ? (
|
||||
<div role="alert">
|
||||
<div role="alert" className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<p>{t.extensions.unavailable}</p>
|
||||
<Button variant="outline" onClick={() => void source.refetch()}>
|
||||
<Button variant="outline" size="sm" onClick={() => void source.refetch()}>
|
||||
{t.extensions.retry}
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
const reload = (
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
{t.extensions.reloadAll}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground -mr-2.5"
|
||||
aria-label={t.extensions.reloadAll}
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<RefreshCwIcon />
|
||||
<span className="hidden sm:inline">{t.extensions.reloadAll}</span>
|
||||
</Button>
|
||||
);
|
||||
if (selected) {
|
||||
const entry = entries.find((item) => item.id === selected);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{reload}
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2.5"
|
||||
onClick={() => select()}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
{t.extensions.all}
|
||||
</Button>
|
||||
{reload}
|
||||
</div>
|
||||
{status}
|
||||
<Button variant="ghost" onClick={() => select()}>
|
||||
<ArrowLeftIcon />
|
||||
{t.extensions.all}
|
||||
</Button>
|
||||
{!entry ? (
|
||||
<p>{t.extensions.notInstalled}</p>
|
||||
<p className="text-muted-foreground py-8">
|
||||
{t.extensions.notInstalled}
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<h2 className="text-2xl font-semibold">{entry.title}</h2>
|
||||
<p className="text-muted-foreground">{entry.description}</p>
|
||||
<p>
|
||||
{entry.loaded?.error
|
||||
? t.extensions.moduleUnavailable
|
||||
: entry.loaded
|
||||
? entry.loaded.settings.enabled === true
|
||||
? t.extensions.enabledManaged
|
||||
: t.extensions.disabledManaged
|
||||
: t.extensions.catalogHint}
|
||||
</p>
|
||||
{entry.guide && (
|
||||
<Button variant="outline" asChild>
|
||||
<a href={entry.guide} target="_blank" rel="noopener noreferrer">
|
||||
{t.extensions.installationGuide}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ExtensionDetail entry={entry} copy={t.extensions} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -89,25 +171,22 @@ export function ExtensionGallery({ query = "" }: { query?: string }) {
|
||||
);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{reload}
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<p className="text-muted-foreground max-w-3xl min-w-0 flex-1 text-sm leading-6">
|
||||
{t.extensions.deploymentHint}
|
||||
</p>
|
||||
{reload}
|
||||
</div>
|
||||
{status}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.extensions.deploymentHint}
|
||||
</p>
|
||||
<div className="grid gap-x-10 md:grid-cols-2">
|
||||
{visible.map((entry) => {
|
||||
const loaded = entry.loaded;
|
||||
const Icon = extensionIcon(loaded?.extension?.icon ?? entry.icon);
|
||||
return (
|
||||
<PluginRow
|
||||
key={entry.id}
|
||||
name={entry.title}
|
||||
description={entry.description}
|
||||
icon={
|
||||
<div className="bg-muted flex size-12 shrink-0 items-center justify-center rounded-xl">
|
||||
<Icon className="size-6" />
|
||||
</div>
|
||||
}
|
||||
icon={<ExtensionIconTile entry={entry} />}
|
||||
label={
|
||||
loaded?.error
|
||||
? t.extensions.moduleUnavailable
|
||||
|
||||
@ -4,12 +4,28 @@ import { mkdir } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { extensionCatalog } from "../../src/core/extensions/catalog";
|
||||
|
||||
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
|
||||
|
||||
// Found by computed style, so renaming the scroller's classes cannot break it.
|
||||
function capabilityCenterScroller(page: Page) {
|
||||
return page
|
||||
.getByRole("heading", { name: "Capability Center" })
|
||||
.evaluateHandle((node) => {
|
||||
let scroller = node.parentElement;
|
||||
while (scroller && getComputedStyle(scroller).overflowY !== "auto") {
|
||||
scroller = scroller.parentElement;
|
||||
}
|
||||
if (!scroller) {
|
||||
throw new Error("Capability Center has no scrolling ancestor");
|
||||
}
|
||||
return scroller;
|
||||
});
|
||||
}
|
||||
|
||||
let gateway: ChildProcess;
|
||||
let gatewayURL: string;
|
||||
test.beforeAll(async ({ request }) => {
|
||||
@ -578,6 +594,15 @@ for (const locale of ["en-US", "zh-CN"]) {
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("deerflow-extension-agent-teams", { exact: true }),
|
||||
).toBeVisible();
|
||||
// The search only filters the directory, so the detail view drops it.
|
||||
await expect(
|
||||
page.getByPlaceholder(
|
||||
zh ? "按名称或用途搜索扩展" : "Search extensions by name or purpose",
|
||||
),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("link", {
|
||||
name: zh ? "查看安装说明" : "Installation guide",
|
||||
@ -663,6 +688,108 @@ for (const locale of ["en-US", "zh-CN"]) {
|
||||
});
|
||||
}
|
||||
|
||||
test("extension detail fits a narrow viewport with its tab in view", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page);
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto(
|
||||
"/workspace/capabilities?tab=extensions&extension=community.agent-teams",
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Agent teams", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("tab", { name: "Extensions", exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
await expect(
|
||||
page.getByRole("button", {
|
||||
name: "Reload extensions (refresh page)",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
const scroller = await capabilityCenterScroller(page);
|
||||
expect(
|
||||
await scroller.evaluate((node) => node.scrollWidth - node.clientWidth),
|
||||
).toBe(0);
|
||||
const note = page.getByRole("note");
|
||||
const noteBox = await note.boundingBox();
|
||||
const textBox = await note
|
||||
.getByText(
|
||||
"This entry comes from the repository extension catalog. Ask your deployment administrator to confirm its installation and activation status.",
|
||||
{ exact: true },
|
||||
)
|
||||
.boundingBox();
|
||||
expect(textBox!.x + textBox!.width).toBeLessThanOrEqual(
|
||||
noteBox!.x + noteBox!.width,
|
||||
);
|
||||
});
|
||||
|
||||
test("capability tabs realign on resize without moving the page", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page);
|
||||
await page.setViewportSize({ width: 375, height: 480 });
|
||||
await page.goto("/workspace/capabilities?tab=extensions");
|
||||
const extensions = page.getByRole("tab", {
|
||||
name: "Extensions",
|
||||
exact: true,
|
||||
});
|
||||
const strip = page
|
||||
.getByRole("tablist")
|
||||
.filter({ has: extensions })
|
||||
.locator("..");
|
||||
const clippedBy = async (tab: Locator) => {
|
||||
const [tabBox, stripBox] = await Promise.all([
|
||||
tab.boundingBox(),
|
||||
strip.boundingBox(),
|
||||
]);
|
||||
return Math.max(
|
||||
stripBox!.x - tabBox!.x,
|
||||
tabBox!.x + tabBox!.width - stripBox!.x - stripBox!.width,
|
||||
);
|
||||
};
|
||||
await expect(extensions).toBeInViewport({ ratio: 1 });
|
||||
|
||||
// A manual swipe stays where the user left it.
|
||||
await strip.evaluate((node) => {
|
||||
node.scrollLeft = 0;
|
||||
});
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
),
|
||||
);
|
||||
expect(await strip.evaluate((node) => node.scrollLeft)).toBe(0);
|
||||
expect(await clippedBy(extensions)).toBeGreaterThan(0);
|
||||
|
||||
// Scrolled past the strip, a resize realigns it and leaves the page alone.
|
||||
const scroller = await capabilityCenterScroller(page);
|
||||
await scroller.evaluate((node) => {
|
||||
node.scrollTop = node.scrollHeight;
|
||||
});
|
||||
await expect(strip).not.toBeInViewport();
|
||||
await page.setViewportSize({ width: 320, height: 480 });
|
||||
await expect.poll(() => clippedBy(extensions)).toBeLessThan(1);
|
||||
await expect(strip).not.toBeInViewport();
|
||||
|
||||
// The same holds for a tab clipped on the other side.
|
||||
await page.setViewportSize({ width: 375, height: 480 });
|
||||
await page.goto("/workspace/capabilities?tab=plugins");
|
||||
const tools = page.getByRole("tab", {
|
||||
name: "Tools & integrations",
|
||||
exact: true,
|
||||
});
|
||||
await expect(tools).toBeInViewport({ ratio: 1 });
|
||||
await strip.evaluate((node) => {
|
||||
node.scrollLeft = node.scrollWidth;
|
||||
});
|
||||
expect(await clippedBy(tools)).toBeGreaterThan(0);
|
||||
await page.setViewportSize({ width: 320, height: 480 });
|
||||
await expect.poll(() => clippedBy(tools)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("unregistered plugin pages stay unavailable", async ({ page }) => {
|
||||
mockLangGraphAPI(page);
|
||||
await page.goto("/workspace/extensions/missing.plugin/library");
|
||||
|
||||
@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
|
||||
const galleryMock = rs.hoisted(() => ({
|
||||
search: "",
|
||||
loaded: [] as unknown[],
|
||||
replace: rs.fn(),
|
||||
}));
|
||||
|
||||
rs.mock("next/navigation", () => ({
|
||||
usePathname: () => "/workspace/capabilities",
|
||||
useRouter: () => ({ replace: galleryMock.replace }),
|
||||
useSearchParams: () => new URLSearchParams(galleryMock.search),
|
||||
}));
|
||||
rs.mock("@/core/extensions/hooks", () => ({
|
||||
useFrontendExtensions: () => ({
|
||||
data: galleryMock.loaded,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
refetch: rs.fn(),
|
||||
}),
|
||||
}));
|
||||
rs.mock("@/core/i18n/hooks", () => ({
|
||||
useI18n: () => ({ locale: "en-US", t: enUS }),
|
||||
}));
|
||||
|
||||
import { ExtensionGallery } from "@/components/workspace/capabilities/extension-gallery";
|
||||
import { extensionCatalog } from "@/core/extensions/catalog";
|
||||
import { enUS } from "@/core/i18n/locales/en-US";
|
||||
|
||||
const copy = enUS.extensions;
|
||||
|
||||
function contribution(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
namespace: "community.bookmarks",
|
||||
module: null,
|
||||
entry: null,
|
||||
title: "Loaded title",
|
||||
description: "Loaded description",
|
||||
settings: { enabled: true },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
galleryMock.search = "";
|
||||
galleryMock.loaded = [];
|
||||
galleryMock.replace.mockClear();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("extension detail", () => {
|
||||
it("shows identity, catalog status and the guide for a catalog entry", () => {
|
||||
galleryMock.search = "tab=extensions&extension=community.agent-teams";
|
||||
render(<ExtensionGallery />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: copy.catalog.agentTeams.title }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(copy.catalog.agentTeams.description)).toBeTruthy();
|
||||
expect(screen.getByText("deerflow-extension-agent-teams")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: copy.all })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: copy.reloadAll })).toBeTruthy();
|
||||
|
||||
const notice = screen.getByRole("note");
|
||||
expect(within(notice).getByText(copy.catalogHint)).toBeTruthy();
|
||||
expect(
|
||||
within(notice)
|
||||
.getByRole("link", { name: copy.installationGuide })
|
||||
.getAttribute("href"),
|
||||
).toMatch(/deerflow-extension-agent-teams#readme$/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["enabled", { settings: { enabled: true } }, copy.enabledManaged],
|
||||
["disabled", { settings: { enabled: false } }, copy.disabledManaged],
|
||||
])("reports a loaded %s extension as a note", (_name, overrides, text) => {
|
||||
galleryMock.search = "tab=extensions&extension=community.bookmarks";
|
||||
galleryMock.loaded = [contribution(overrides)];
|
||||
render(<ExtensionGallery />);
|
||||
|
||||
expect(within(screen.getByRole("note")).getByText(text)).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("raises a module failure as an alert", () => {
|
||||
galleryMock.search = "tab=extensions&extension=community.bookmarks";
|
||||
galleryMock.loaded = [contribution({ error: "boom" })];
|
||||
render(<ExtensionGallery />);
|
||||
|
||||
expect(
|
||||
within(screen.getByRole("alert")).getByText(copy.moduleUnavailable),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole("note")).toBeNull();
|
||||
});
|
||||
|
||||
it("omits package and guide for an extension outside the catalog", () => {
|
||||
galleryMock.search = "tab=extensions&extension=acme.private";
|
||||
galleryMock.loaded = [contribution({ namespace: "acme.private" })];
|
||||
render(<ExtensionGallery />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Loaded title" })).toBeTruthy();
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
expect(screen.queryByText(/deerflow-extension-/)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the way back when the extension is unknown", () => {
|
||||
galleryMock.search = "tab=extensions&extension=missing.plugin";
|
||||
render(<ExtensionGallery />);
|
||||
|
||||
expect(screen.getByText(copy.notInstalled)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: copy.all })).toBeTruthy();
|
||||
expect(screen.queryByRole("note")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extension directory", () => {
|
||||
it("keeps the reload action beside the deployment hint", () => {
|
||||
galleryMock.search = "tab=extensions";
|
||||
render(<ExtensionGallery />);
|
||||
|
||||
const reload = screen.getByRole("button", { name: copy.reloadAll });
|
||||
expect(reload.parentElement).toBe(
|
||||
screen.getByText(copy.deploymentHint).parentElement,
|
||||
);
|
||||
expect(screen.getAllByRole("article")).toHaveLength(
|
||||
extensionCatalog.length,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user