fix: simplify plugin settings layout (#3914)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -227,7 +227,9 @@ describe("PluginsSettingsPage", () => {
|
||||
render(<PluginsSettingsPage />);
|
||||
|
||||
expect(screen.getByText(PLUGIN_DISPLAY_NAME)).toBeTruthy();
|
||||
expect(screen.getByText(/acme-tools.*v1\.0\.0/)).toBeTruthy();
|
||||
expect(screen.getByTestId(`plugin-row-${PLUGIN_ID}`).textContent).toMatch(
|
||||
/acme-tools.*v1\.0\.0/,
|
||||
);
|
||||
expect(screen.getAllByText(/active/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByTestId("plugin-unsigned-badge")).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -120,7 +120,7 @@ export function PluginRow({
|
||||
return (
|
||||
<div
|
||||
data-testid={`plugin-row-${plugin.id}`}
|
||||
className="group relative rounded-lg border border-border/70 bg-background p-4 transition-colors hover:border-border hover:bg-muted"
|
||||
className="group relative min-w-0 py-4 transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<PluginRowContent
|
||||
plugin={plugin}
|
||||
@@ -214,7 +214,7 @@ function PluginRowContent({
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<PluginRowIdentity plugin={plugin} needsSetup={needsSetup} update={update} />
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex min-w-0 max-w-full items-center gap-2">
|
||||
{canManage && (
|
||||
<PluginRowActions
|
||||
plugin={plugin}
|
||||
@@ -320,7 +320,7 @@ function PluginRowIdentity({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="min-w-0 max-w-full space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground truncate group-hover:underline">
|
||||
{plugin.display_name}
|
||||
@@ -349,9 +349,10 @@ function PluginRowIdentity({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="font-mono truncate">
|
||||
{plugin.id} · v{plugin.version}
|
||||
<span className="font-mono truncate" title={plugin.id}>
|
||||
{plugin.id}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono">v{plugin.version}</span>
|
||||
<PluginRepoLink url={plugin.repo_url} className="relative z-10" />
|
||||
<PluginUpdateInfo pluginId={plugin.id} update={update} />
|
||||
</div>
|
||||
@@ -493,7 +494,7 @@ function PluginRowActions({
|
||||
const { isMobile } = useResponsiveBreakpoint();
|
||||
const updateEntry = update?.hasUpdate ? update.latest : undefined;
|
||||
return (
|
||||
<div className="relative z-10 flex flex-wrap items-center gap-2 shrink-0">
|
||||
<div className="relative z-10 flex min-w-0 flex-wrap items-center gap-2">
|
||||
{updateEntry && onUpdate && (
|
||||
<PluginUpdateButton
|
||||
pluginId={plugin.id}
|
||||
|
||||
@@ -22,7 +22,8 @@ import { PluginUpdateStatus } from "./plugin-update-status";
|
||||
import { usePluginActions } from "./use-plugin-actions";
|
||||
import { usePluginUpdateAction } from "./use-plugin-update-action";
|
||||
import { settingsActionClassName } from "@/components/settings/settings-control";
|
||||
import { SettingsGroup } from "@/components/settings/settings-group";
|
||||
import { SettingsRow } from "@/components/settings/settings-group";
|
||||
import { SETTINGS_TYPOGRAPHY } from "@/components/settings/settings-typography";
|
||||
|
||||
/**
|
||||
* Operator UI to browse, install, enable, disable, uninstall, and update kandev
|
||||
@@ -60,8 +61,9 @@ export function PluginsSettings() {
|
||||
saveStatus="idle"
|
||||
onSave={() => undefined}
|
||||
showSaveButton={false}
|
||||
contentFrame="none"
|
||||
>
|
||||
<Tabs defaultValue="installed" className="space-y-6">
|
||||
<Tabs defaultValue="installed" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
value="installed"
|
||||
@@ -84,7 +86,7 @@ export function PluginsSettings() {
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="installed" className="space-y-6">
|
||||
<TabsContent value="installed" className="space-y-4">
|
||||
<InstalledTab
|
||||
list={list}
|
||||
actions={actions}
|
||||
@@ -141,46 +143,9 @@ function InstalledTab({
|
||||
updateAction,
|
||||
isFinePointer,
|
||||
}: InstalledTabProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title={t("plugins:installedPlugins")}
|
||||
contentClassName="space-y-4 divide-y-0"
|
||||
action={
|
||||
canManage ? (
|
||||
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
|
||||
<Button
|
||||
data-testid="plugins-sync-button"
|
||||
variant="secondary"
|
||||
disabled={actions.syncBusy}
|
||||
onClick={actions.handleSync}
|
||||
className={settingsActionClassName("cursor-pointer")}
|
||||
>
|
||||
<IconRefresh className={`h-4 w-4 ${actions.syncBusy ? "animate-spin" : ""}`} />
|
||||
{t("plugins:sync")}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="plugins-check-updates-button"
|
||||
variant="secondary"
|
||||
disabled={updates.checking}
|
||||
onClick={updates.checkForUpdates}
|
||||
className={settingsActionClassName("cursor-pointer")}
|
||||
>
|
||||
<IconRefresh className={`h-4 w-4 ${updates.checking ? "animate-spin" : ""}`} />
|
||||
{t("plugins:checkForUpdates")}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="install-plugin-trigger"
|
||||
onClick={actions.openInstall}
|
||||
className={settingsActionClassName("cursor-pointer")}
|
||||
>
|
||||
{t("plugins:installPlugin")}
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<section className="min-w-0 space-y-3" aria-labelledby="installed-plugins-heading">
|
||||
<InstalledPluginsToolbar actions={actions} updates={updates} canManage={canManage} />
|
||||
{canManage && <GlobalAutoUpdateToggle settings={autoUpdate} />}
|
||||
|
||||
<PluginUpdateStatus
|
||||
@@ -211,7 +176,61 @@ function InstalledTab({
|
||||
updateAction={updateAction}
|
||||
isFinePointer={isFinePointer}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledPluginsToolbar({
|
||||
actions,
|
||||
updates,
|
||||
canManage,
|
||||
}: Pick<InstalledTabProps, "actions" | "updates" | "canManage">) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<h3 id="installed-plugins-heading" className={SETTINGS_TYPOGRAPHY.sectionTitle}>
|
||||
{t("plugins:installedPlugins")}
|
||||
</h3>
|
||||
{canManage && (
|
||||
<>
|
||||
<Button
|
||||
data-testid="install-plugin-trigger"
|
||||
onClick={actions.openInstall}
|
||||
className={settingsActionClassName("ml-auto cursor-pointer md:ml-0")}
|
||||
>
|
||||
{t("plugins:installPlugin")}
|
||||
</Button>
|
||||
<div className="flex w-full flex-wrap items-center gap-1 md:ml-auto md:w-auto">
|
||||
<Button
|
||||
data-testid="plugins-sync-button"
|
||||
variant="ghost"
|
||||
disabled={actions.syncBusy}
|
||||
onClick={actions.handleSync}
|
||||
className={settingsActionClassName("cursor-pointer text-muted-foreground")}
|
||||
>
|
||||
<IconRefresh
|
||||
className={`size-4 ${actions.syncBusy ? "animate-spin" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("plugins:sync")}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="plugins-check-updates-button"
|
||||
variant="ghost"
|
||||
disabled={updates.checking}
|
||||
onClick={updates.checkForUpdates}
|
||||
className={settingsActionClassName("cursor-pointer text-muted-foreground")}
|
||||
>
|
||||
<IconRefresh
|
||||
className={`size-4 ${updates.checking ? "animate-spin" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("plugins:checkForUpdates")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,25 +246,24 @@ function GlobalAutoUpdateToggle({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/70 bg-background p-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<label
|
||||
htmlFor="plugins-auto-update-default"
|
||||
className="text-sm font-medium text-foreground cursor-pointer"
|
||||
>
|
||||
{t("plugins:autoUpdateTitle")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{t("plugins:autoUpdateDescription")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="plugins-auto-update-default"
|
||||
data-testid="plugins-auto-update-default"
|
||||
checked={settings.autoUpdateDefault}
|
||||
disabled={!settings.loaded}
|
||||
onCheckedChange={settings.setDefault}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<SettingsRow
|
||||
label={<span className="text-sm">{t("plugins:autoUpdateTitle")}</span>}
|
||||
description={t("plugins:autoUpdateDescription")}
|
||||
controlId="plugins-auto-update-default"
|
||||
touchTarget="switch"
|
||||
className="flex-row items-center gap-4 border-y border-border/60"
|
||||
controlWrapperClassName="max-md:w-auto"
|
||||
control={
|
||||
<Switch
|
||||
id="plugins-auto-update-default"
|
||||
data-testid="plugins-auto-update-default"
|
||||
checked={settings.autoUpdateDefault}
|
||||
disabled={!settings.loaded}
|
||||
onCheckedChange={settings.setDefault}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -297,7 +315,7 @@ function PluginList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="divide-y divide-border/60">
|
||||
{items.map((plugin) => {
|
||||
const rowUpdate: PluginRowUpdateState = {
|
||||
latest: updates.latestById.get(plugin.id),
|
||||
|
||||
@@ -62,6 +62,24 @@ import {
|
||||
const NAV_ITEM_ID = "e2e-hello";
|
||||
const PLUGIN_ROUTE = "/plugins/e2e-hello";
|
||||
|
||||
// @covers AC-UI-CONTROL-SIZING-001.1, AC-UI-CONTROL-SIZING-001.3, AC-UI-CONTROL-SIZING-001.8
|
||||
test("plugin settings toolbar stays compact on desktop", async ({ testPage }) => {
|
||||
await testPage.goto("/settings/plugins");
|
||||
const install = testPage.getByTestId("install-plugin-trigger");
|
||||
const sync = testPage.getByTestId("plugins-sync-button");
|
||||
const check = testPage.getByTestId("plugins-check-updates-button");
|
||||
await expect(install).toBeVisible();
|
||||
const boxes = await Promise.all([install.boundingBox(), sync.boundingBox(), check.boundingBox()]);
|
||||
for (const box of boxes) {
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.height).toBeCloseTo(28, 0);
|
||||
expect(box!.y).toBeCloseTo(boxes[0]!.y, 0);
|
||||
}
|
||||
await install.focus();
|
||||
await testPage.keyboard.press("Enter");
|
||||
await expect(testPage.getByTestId("install-plugin-dialog")).toBeVisible();
|
||||
});
|
||||
|
||||
/** Every deliveries.jsonl `event_type` recorded so far, read straight off
|
||||
* disk from the plugin's real KANDEV_PLUGIN_DATA_DIR (no in-process mock —
|
||||
* this is the fixture Go binary's own gRPC OnEvent handler writing to its
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
uploadPackage,
|
||||
} from "../plugins/plugin-test-helpers";
|
||||
|
||||
// @covers AC-UI-CONTROL-SIZING-001.3, AC-UI-CONTROL-SIZING-001.4, AC-UI-CONTROL-SIZING-001.5, AC-UI-CONTROL-SIZING-001.8
|
||||
test.describe("Mobile plugin updates", () => {
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
await uninstallPluginFixture(apiClient);
|
||||
@@ -14,6 +15,7 @@ test.describe("Mobile plugin updates", () => {
|
||||
|
||||
test("checks and updates marketplace state from the mobile settings flow", async ({
|
||||
testPage,
|
||||
prCapture,
|
||||
}) => {
|
||||
await openInstallDialog(testPage);
|
||||
await uploadPackage(testPage, PACKAGE_PATH);
|
||||
@@ -81,6 +83,19 @@ test.describe("Mobile plugin updates", () => {
|
||||
expect(buttonBox).not.toBeNull();
|
||||
expect(buttonBox!.height).toBeGreaterThanOrEqual(44);
|
||||
|
||||
await prCapture.screenshot("plugin-settings", { caption: "Plugin settings on a phone" });
|
||||
const syncBox = await testPage.getByTestId("plugins-sync-button").boundingBox();
|
||||
const installBox = await testPage.getByTestId("install-plugin-trigger").boundingBox();
|
||||
const headingBox = await testPage
|
||||
.getByRole("heading", { name: "Installed plugins", exact: true })
|
||||
.boundingBox();
|
||||
expect(syncBox).not.toBeNull();
|
||||
expect(installBox).not.toBeNull();
|
||||
expect(headingBox).not.toBeNull();
|
||||
expect(syncBox!.y).toBeCloseTo(buttonBox!.y, 0);
|
||||
expect(installBox!.y).toBeLessThan(headingBox!.y + headingBox!.height);
|
||||
expect(installBox!.width).toBeLessThan(testPage.viewportSize()!.width / 2);
|
||||
|
||||
await checkButton.tap();
|
||||
|
||||
await expect.poll(() => refreshRequests).toBe(1);
|
||||
@@ -106,5 +121,27 @@ test.describe("Mobile plugin updates", () => {
|
||||
expect(
|
||||
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
).toBe(true);
|
||||
|
||||
for (const width of [320, 767, 768]) {
|
||||
await testPage.setViewportSize({ width, height: 851 });
|
||||
const sync = testPage.getByTestId("plugins-sync-button");
|
||||
const install = testPage.getByTestId("install-plugin-trigger");
|
||||
await expect(sync).toBeVisible();
|
||||
await expect(install).toBeVisible();
|
||||
const [syncBounds, checkBounds, installBounds] = await Promise.all([
|
||||
sync.boundingBox(),
|
||||
checkButton.boundingBox(),
|
||||
install.boundingBox(),
|
||||
]);
|
||||
expect(syncBounds!.y).toBeCloseTo(checkBounds!.y, 0);
|
||||
for (const box of [syncBounds!, checkBounds!, installBounds!]) {
|
||||
expect(box.height).toBeGreaterThanOrEqual(44);
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(width);
|
||||
}
|
||||
expect(
|
||||
await testPage.evaluate(() => document.documentElement.scrollWidth),
|
||||
).toBeLessThanOrEqual(width);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user