Files
kandev/apps/web/components/settings/plugins/plugin-row.tsx
T
2026-08-05 12:53:55 +02:00

257 lines
8.1 KiB
TypeScript

"use client";
import { IconArrowUpCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Switch } from "@kandev/ui/switch";
import Link from "@/components/routing/app-link";
import { PluginRepoLink } from "./plugin-repo-link";
import { PluginStatusBadge } from "./plugin-status-badge";
import { PluginErrorDiagnostic } from "./plugin-error-diagnostic";
import type { MarketplaceEntry, PluginRecord } from "@/lib/types/plugins";
type PluginRowProps = {
plugin: PluginRecord;
busy: boolean;
/** Set when the marketplace has a newer version than the installed one. */
update?: MarketplaceEntry;
/** The instance-wide auto-update default, used when the plugin has no override. */
autoUpdateDefault: boolean;
/** True while this row's auto-update override request is in flight. */
autoUpdateBusy: boolean;
onEnable: (plugin: PluginRecord) => void;
onDisable: (plugin: PluginRecord) => void;
onUninstall: (plugin: PluginRecord) => void;
onUpdate?: (entry: MarketplaceEntry) => void;
onSetAutoUpdate: (plugin: PluginRecord, value: boolean | null) => void;
};
/**
* One plugin's row. Div-based (not a `<table>`) so it wraps/stacks naturally
* on narrow viewports and inside the mobile settings sheet — no separate
* mobile layout needed.
*/
export function PluginRow({
plugin,
busy,
update,
autoUpdateDefault,
autoUpdateBusy,
onEnable,
onDisable,
onUninstall,
onUpdate,
onSetAutoUpdate,
}: PluginRowProps) {
const { t } = useTranslation();
const canEnable =
plugin.status === "disabled" || plugin.status === "registered" || plugin.status === "error";
const canDisable = plugin.status === "active" || plugin.status === "error";
return (
<div
data-testid={`plugin-row-${plugin.id}`}
className="rounded-lg border border-border/70 bg-background p-4 space-y-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/settings/plugins/${encodeURIComponent(plugin.id)}`}
data-testid={`plugin-row-link-${plugin.id}`}
className="text-sm font-medium text-foreground truncate cursor-pointer hover:underline"
>
{plugin.display_name}
</Link>
<PluginStatusBadge status={plugin.status} />
{plugin.signed === false && (
<Badge
data-testid="plugin-unsigned-badge"
variant="outline"
className="border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[11px]"
>
{t("plugins:unsigned")}
</Badge>
)}
</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>
<PluginRepoLink url={plugin.repo_url} />
</div>
</div>
<PluginRowActions
plugin={plugin}
busy={busy}
update={update}
canEnable={canEnable}
canDisable={canDisable}
onEnable={onEnable}
onDisable={onDisable}
onUninstall={onUninstall}
onUpdate={onUpdate}
/>
</div>
{plugin.description && (
<div className="text-xs text-muted-foreground">{plugin.description}</div>
)}
<PluginErrorDiagnostic plugin={plugin} />
{plugin.categories.length > 0 && (
<div className="flex flex-wrap gap-1">
{plugin.categories.map((category) => (
<Badge key={category} variant="secondary" className="text-[11px]">
{category}
</Badge>
))}
</div>
)}
<PluginAutoUpdateRow
plugin={plugin}
autoUpdateDefault={autoUpdateDefault}
busy={autoUpdateBusy}
onSetAutoUpdate={onSetAutoUpdate}
/>
</div>
);
}
/**
* The per-plugin auto-update control. The switch reflects the effective state
* (the plugin's own override, or the instance-wide default when it has none);
* toggling it sets an explicit override. Once overridden, a "Reset" affordance
* clears the override so the plugin follows the global default again.
*/
function PluginAutoUpdateRow({
plugin,
autoUpdateDefault,
busy,
onSetAutoUpdate,
}: {
plugin: PluginRecord;
autoUpdateDefault: boolean;
busy: boolean;
onSetAutoUpdate: (plugin: PluginRecord, value: boolean | null) => void;
}) {
const { t } = useTranslation();
const isOverridden = plugin.auto_update !== null && plugin.auto_update !== undefined;
const effective = isOverridden ? (plugin.auto_update as boolean) : autoUpdateDefault;
return (
<div className="flex items-center justify-between gap-3 border-t border-border/50 pt-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{t("plugins:autoUpdate")}</span>
{isOverridden && (
<Badge variant="outline" className="text-[11px]">
{t("plugins:override")}
</Badge>
)}
</div>
<div className="flex items-center gap-2">
{isOverridden && (
<button
type="button"
data-testid={`plugin-auto-update-reset-${plugin.id}`}
aria-label={t("plugins:resetAutoUpdateFor", { name: plugin.display_name })}
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline cursor-pointer disabled:opacity-50"
disabled={busy}
onClick={() => onSetAutoUpdate(plugin, null)}
>
{t("plugins:reset")}
</button>
)}
<Switch
data-testid={`plugin-auto-update-${plugin.id}`}
aria-label={t("plugins:autoUpdateFor", { name: plugin.display_name })}
checked={effective}
disabled={busy}
onCheckedChange={(value) => onSetAutoUpdate(plugin, value)}
className="cursor-pointer"
/>
</div>
</div>
);
}
type PluginRowActionsProps = Omit<
PluginRowProps,
| "onEnable"
| "onDisable"
| "onUninstall"
| "autoUpdateDefault"
| "autoUpdateBusy"
| "onSetAutoUpdate"
> & {
canEnable: boolean;
canDisable: boolean;
onEnable: (plugin: PluginRecord) => void;
onDisable: (plugin: PluginRecord) => void;
onUninstall: (plugin: PluginRecord) => void;
};
function PluginRowActions({
plugin,
busy,
update,
canEnable,
canDisable,
onEnable,
onDisable,
onUninstall,
onUpdate,
}: PluginRowActionsProps) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-2 shrink-0">
{update && onUpdate && (
<Button
variant="outline"
size="sm"
data-testid={`plugin-update-${plugin.id}`}
className="cursor-pointer gap-1 min-h-11 sm:min-h-0"
disabled={busy}
onClick={() => onUpdate(update)}
>
<IconArrowUpCircle className="h-4 w-4" />
{busy ? t("plugins:updating") : t("plugins:updateToVersion", { version: update.version })}
</Button>
)}
{canEnable && (
<Button
variant="outline"
size="sm"
className="cursor-pointer min-h-11 sm:min-h-0"
disabled={busy}
onClick={() => onEnable(plugin)}
>
{t("plugins:enable")}
</Button>
)}
{canDisable && (
<Button
variant="outline"
size="sm"
className="cursor-pointer min-h-11 sm:min-h-0"
disabled={busy}
onClick={() => onDisable(plugin)}
>
{t("plugins:disable")}
</Button>
)}
<Button
variant="ghost"
size="sm"
className="cursor-pointer min-h-11 text-destructive hover:text-destructive sm:min-h-0"
disabled={busy}
onClick={() => onUninstall(plugin)}
>
{t("plugins:uninstall")}
</Button>
</div>
);
}