mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: show plugin names and descriptions in the UI language (#1658)
* fix: show plugin names and descriptions in the UI language Read plugin.name and plugin.description from each plugin's own locale namespace, falling back to the manifest, everywhere core shows a plugin's name: the Plugins manager, onboarding picker, host editor tabs and sections, host filters and bulk actions, beta updates, interface presets and plugin crash/loading notices. * docs(sdk): document plugin.name and plugin.description locale keys --------- Co-authored-by: ZacharyZcR <zkl205305@gmail.com>
This commit is contained in:
1 parent
fc77b6c756
commit
764d034098
20 files changed
+236
-45
No files matched your search
@@ -105,7 +105,7 @@ Before you call a change finished:
|
||||
- [ ] `npm run build` passes
|
||||
- [ ] `npm run format:check` passes
|
||||
- [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables)
|
||||
- [ ] New strings are in `locales/en.json`
|
||||
- [ ] New strings are in `locales/en.json`, and `plugin.name` / `plugin.description` match the manifest
|
||||
- [ ] New routes have an `@openapi` block and a test, including without the permission
|
||||
- [ ] New env vars are listed in `env` in the manifest
|
||||
- [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does
|
||||
@@ -538,6 +538,20 @@ Plurals use `_one` and `_other` with `t("hosts", { count })`. Other languages go
|
||||
`locales/translated/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are.
|
||||
Untranslated strings show in English.
|
||||
|
||||
Termix shows your plugin's name and description in the plugin manager, the setup picker and settings.
|
||||
Put them in `locales/en.json` as `plugin.name` and `plugin.description`, with the same text as `name` and
|
||||
`description` in the manifest, so they get translated too. Without them, the English manifest text shows in
|
||||
every language.
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": {
|
||||
"name": "Host Metrics",
|
||||
"description": "Live CPU, memory, disk, network and GPU stats."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
`docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
/**
|
||||
* Where a plugin's i18n keys resolve from.
|
||||
*
|
||||
@@ -19,3 +21,25 @@ export function pluginKey(
|
||||
if (!key) return key;
|
||||
return key.includes(":") ? key : `${pluginId}:${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin's name in the UI language: "plugin.name" from its own locales,
|
||||
* else the manifest's. A plugin whose strings are not loaded, like one only
|
||||
* listed in a registry, shows the manifest name.
|
||||
*/
|
||||
export function pluginName(
|
||||
t: TFunction,
|
||||
plugin: { id: string; name: string },
|
||||
): string {
|
||||
return t(pluginKey(plugin.id, "plugin.name"), { defaultValue: plugin.name });
|
||||
}
|
||||
|
||||
/** Same as pluginName, for "plugin.description". */
|
||||
export function pluginDescription(
|
||||
t: TFunction,
|
||||
plugin: { id: string; description?: string | null },
|
||||
): string {
|
||||
return t(pluginKey(plugin.id, "plugin.description"), {
|
||||
defaultValue: plugin.description ?? "",
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
recommendedChoices,
|
||||
} from "../plugin-choices";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"Terminal",
|
||||
@@ -79,8 +80,8 @@ export function PluginsStep({
|
||||
}, [list, choices]);
|
||||
|
||||
const names = useMemo(
|
||||
() => new Map(list?.plugins.map((p) => [p.id, p.name]) ?? []),
|
||||
[list],
|
||||
() => new Map(list?.plugins.map((p) => [p.id, pluginName(t, p)]) ?? []),
|
||||
[list, t],
|
||||
);
|
||||
|
||||
const changed = useMemo(() => {
|
||||
@@ -260,7 +261,7 @@ export function PluginsStep({
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium">
|
||||
{plugin.name}
|
||||
{pluginName(t, plugin)}
|
||||
{plugin.recommended && (
|
||||
<span className="border border-accent-brand/40 px-1 text-[9px] font-semibold uppercase tracking-wider text-accent-brand">
|
||||
{t("onboarding.pluginsRecommended")}
|
||||
@@ -268,7 +269,7 @@ export function PluginsStep({
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{plugin.description}
|
||||
{pluginDescription(t, plugin)}
|
||||
</span>
|
||||
{forcedBy.has(plugin.id) && (
|
||||
<span className="text-[10px] text-accent-brand">
|
||||
@@ -281,7 +282,7 @@ export function PluginsStep({
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={plugin.name}
|
||||
aria-label={pluginName(t, plugin)}
|
||||
className="flex shrink-0 border border-border"
|
||||
>
|
||||
{CHOICES.map((option) => (
|
||||
@@ -335,9 +336,11 @@ export function PluginsStep({
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">{plugin.name}</span>
|
||||
<span className="text-xs font-medium">
|
||||
{pluginName(t, plugin)}
|
||||
</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{plugin.description}
|
||||
{pluginDescription(t, plugin)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/80">
|
||||
{t("onboarding.pluginsConsentNote")}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CircleSlash } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { pluginName } from "@/lib/plugin-i18n";
|
||||
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
||||
import { usePluginStore } from "./plugin-store";
|
||||
import { unregisteredViewStatus, type ViewKind } from "./view-ownership";
|
||||
@@ -27,7 +28,7 @@ export function PluginViewPlaceholder({
|
||||
usePluginStore();
|
||||
const { has } = usePermissions();
|
||||
const { status, owner } = unregisteredViewStatus(kind, viewId);
|
||||
const name = owner?.summary.name ?? viewId;
|
||||
const name = owner ? pluginName(t, owner.summary) : viewId;
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "react";
|
||||
import { scopeHostFields } from "./host-scope";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { pluginName } from "@/lib/plugin-i18n";
|
||||
import { getPluginRecord } from "./plugin-store";
|
||||
|
||||
const PluginScopeContext = createContext<string | null>(null);
|
||||
@@ -27,7 +28,8 @@ function CrashNotice({
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const name = getPluginRecord(pluginId)?.summary.name ?? pluginId;
|
||||
const summary = getPluginRecord(pluginId)?.summary;
|
||||
const name = summary ? pluginName(t, summary) : pluginId;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
|
||||
<span>{t("plugins.runtime.crashed", { name })}</span>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type RegistryListing,
|
||||
} from "@/api/plugins-api";
|
||||
import { useConfirm } from "@/components/surface/surface-scope";
|
||||
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
|
||||
import { mergePlugins, uploadEntry, type PluginEntry } from "./plugin-model";
|
||||
import type { ConsentRequest } from "./PluginConsentPrompt";
|
||||
|
||||
@@ -109,9 +110,16 @@ export function usePluginsManager() {
|
||||
window.removeEventListener(DEVELOPER_MODE_CHANGED_EVENT, onChange);
|
||||
}, []);
|
||||
|
||||
// Named in the UI language once here, so lists, sorting, search, dialogs
|
||||
// and toasts all show the same name.
|
||||
const plugins = useMemo(
|
||||
() => mergePlugins(installed, registry?.plugins ?? null),
|
||||
[installed, registry],
|
||||
() =>
|
||||
mergePlugins(installed, registry?.plugins ?? null).map((plugin) => ({
|
||||
...plugin,
|
||||
name: pluginName(t, plugin),
|
||||
description: pluginDescription(t, plugin),
|
||||
})),
|
||||
[installed, registry, t],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
|
||||
@@ -11,7 +11,7 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { AccordionSection } from "@/sidebar/AdminSettingsShared";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import {
|
||||
getPluginAdminSettings,
|
||||
@@ -68,9 +68,7 @@ export function FeatureSettingsSection({
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<AccordionSection
|
||||
label={t(pluginKey(plugin.id, "plugin.name"), {
|
||||
defaultValue: plugin.name,
|
||||
})}
|
||||
label={pluginName(t, plugin)}
|
||||
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
@@ -198,9 +196,7 @@ export function FeatureSettingsForm({
|
||||
{!running && (
|
||||
<div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
{t("settings.featureUnavailable", {
|
||||
name: t(pluginKey(plugin.id, "plugin.name"), {
|
||||
defaultValue: plugin.name,
|
||||
}),
|
||||
name: pluginName(t, plugin),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { pluginName } from "@/lib/plugin-i18n";
|
||||
import { type PluginSummary } from "@/api/plugins-api";
|
||||
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
|
||||
import { usePluginScope } from "@/plugin-host/scope";
|
||||
@@ -141,13 +142,15 @@ function HostPluginSection({
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={plugin.name}
|
||||
title={pluginName(t, plugin)}
|
||||
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
||||
action={<DocsLink href={plugin.docs} />}
|
||||
>
|
||||
{!running && (
|
||||
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
{t("settings.featureUnavailable", { name: plugin.name })}
|
||||
{t("settings.featureUnavailable", {
|
||||
name: pluginName(t, plugin),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { EmptyState } from "@/components/empty-state";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { reportCoreIssueUrl } from "@/lib/issue-url";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { UserPreferences } from "@/main-axios";
|
||||
@@ -121,9 +121,7 @@ export function SettingsTab({
|
||||
id: `plugin:${plugin.id}`,
|
||||
band: "plugins",
|
||||
labelKey: pluginKey(plugin.id, "plugin.name"),
|
||||
label: t(pluginKey(plugin.id, "plugin.name"), {
|
||||
defaultValue: plugin.name,
|
||||
}),
|
||||
label: pluginName(t, plugin),
|
||||
icon: SettingsIcon,
|
||||
pluginId: plugin.id,
|
||||
searchKeys: fields.flatMap((field) =>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { isElectron } from "@/lib/electron";
|
||||
import { isPrerelease } from "@/plugins/plugin-model";
|
||||
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
import { pluginName } from "@/lib/plugin-i18n";
|
||||
import { DesktopUpdatesCard } from "./DesktopUpdatesCard";
|
||||
|
||||
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
|
||||
@@ -262,7 +263,7 @@ function InstanceUpdates() {
|
||||
{betaPlugins.map((plugin) => (
|
||||
<SettingRow
|
||||
key={plugin.id}
|
||||
label={plugin.name}
|
||||
label={pluginName(t, plugin)}
|
||||
description={
|
||||
plugin.channel === "beta"
|
||||
? `v${plugin.version}`
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import {
|
||||
hostEditorSectionList,
|
||||
registerHostEditorSection,
|
||||
@@ -95,7 +96,7 @@ export function syncHostFeatureTabs(plugins: PluginSummary[]): string[] {
|
||||
pluginId: plugin.id,
|
||||
group: placement?.group ?? host.editorGroup ?? "top",
|
||||
order: placement?.order ?? host.editorOrder ?? 100,
|
||||
labelKey: plugin.name,
|
||||
labelKey: pluginKey(plugin.id, "plugin.name"),
|
||||
label: plugin.name,
|
||||
// Nothing but the plugin's host settings, so it works for defaults too.
|
||||
defaults: ownSections.has(plugin.id) ? "only" : true,
|
||||
|
||||
@@ -730,7 +730,7 @@ export function HostEditor({
|
||||
<EditorSection
|
||||
key={id}
|
||||
id={id}
|
||||
label={section.label ?? t(section.labelKey)}
|
||||
label={t(section.labelKey, { defaultValue: section.label })}
|
||||
>
|
||||
<LazySection>
|
||||
<Section
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface HostEditorSectionDef {
|
||||
pluginId?: string;
|
||||
group: "top" | "ssh";
|
||||
labelKey: string;
|
||||
/** Already-translated label, used instead of labelKey when set. */
|
||||
/** Shown when labelKey has no translation. */
|
||||
label?: string;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
/** Position among the group's tabs, core ones included. */
|
||||
@@ -88,7 +88,7 @@ function sectionVisible(
|
||||
function mergeByOrder(
|
||||
core: (HostTab & { order: number })[],
|
||||
group: "top" | "ssh",
|
||||
t: (key: string) => string,
|
||||
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||
protocols: Record<string, boolean> | undefined,
|
||||
defaultsMode = false,
|
||||
): HostTab[] {
|
||||
@@ -104,7 +104,7 @@ function mergeByOrder(
|
||||
const Icon = section.icon;
|
||||
return {
|
||||
id: section.id,
|
||||
label: section.label ?? t(section.labelKey),
|
||||
label: t(section.labelKey, { defaultValue: section.label }),
|
||||
icon: Icon ? <Icon className="size-3" /> : null,
|
||||
order: section.order ?? 1000,
|
||||
};
|
||||
@@ -119,7 +119,7 @@ function mergeByOrder(
|
||||
* `visible`; without, every registered top tab is listed.
|
||||
*/
|
||||
export function makeHostTabs(
|
||||
t: (key: string) => string,
|
||||
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||
protocols?: Record<string, boolean>,
|
||||
defaultsMode = false,
|
||||
): HostTab[] {
|
||||
@@ -146,7 +146,7 @@ export function makeHostTabs(
|
||||
}
|
||||
|
||||
export function makeHostSshSubTabs(
|
||||
t: (key: string) => string,
|
||||
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||
protocols?: Record<string, boolean>,
|
||||
defaultsMode = false,
|
||||
): HostTab[] {
|
||||
@@ -167,7 +167,7 @@ export function makeHostSshSubTabs(
|
||||
}
|
||||
|
||||
export function makeCredentialTabs(
|
||||
t: (key: string) => string,
|
||||
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||
): CredentialTab[] {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -55,6 +55,7 @@ import type { Host, HostFolder, TabType } from "@/types/ui-types";
|
||||
import { sortHostTree, type SortKey } from "@/sidebar/host-sort";
|
||||
import { withLiveStatus } from "@/sidebar/live-host-status";
|
||||
import { useServerStatus } from "@/lib/ServerStatusContext";
|
||||
import { pluginName } from "@/lib/plugin-i18n";
|
||||
import { useHostSidebarPreferences } from "@/sidebar/tree/hooks/useHostSidebarPreferences";
|
||||
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
|
||||
import type {
|
||||
@@ -816,7 +817,7 @@ export function HostsPanel({
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{plugin.name}
|
||||
{pluginName(t, plugin)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
{allTags.length > 0 && (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Button } from "@/components/button";
|
||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
|
||||
import { pluginName } from "@/lib/plugin-i18n";
|
||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||
import {
|
||||
UI_AREA_KEYS,
|
||||
@@ -67,9 +68,10 @@ export function InterfacePresetSettings({
|
||||
)
|
||||
.map((area) => {
|
||||
const pluginId = area.slice("plugin:".length);
|
||||
const summary = pluginStore.records.get(pluginId)?.summary;
|
||||
return {
|
||||
key: area,
|
||||
label: pluginStore.records.get(pluginId)?.summary.name ?? pluginId,
|
||||
label: summary ? pluginName(t, summary) : pluginId,
|
||||
};
|
||||
});
|
||||
return [...core, ...plugins];
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { updatePluginHostSettings } from "@/api/plugins-api";
|
||||
import { resetHostToDefaults } from "@/api/host-defaults-api";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { pluginName } from "@/lib/plugin-i18n";
|
||||
import { usePluginHostSections } from "@/settings/HostPluginSections";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
@@ -1284,7 +1285,7 @@ export function SidebarTree({
|
||||
value
|
||||
? "hosts.enablePluginFeature"
|
||||
: "hosts.disablePluginFeature",
|
||||
{ name: plugin.name },
|
||||
{ name: pluginName(t, plugin) },
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)),
|
||||
@@ -1307,7 +1308,7 @@ export function SidebarTree({
|
||||
{ id: "core", label: t("hostDefaults.resetCore") },
|
||||
...hostSettingPlugins.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
label: plugin.name,
|
||||
label: pluginName(t, plugin),
|
||||
})),
|
||||
].map((option) => (
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import i18next from "i18next";
|
||||
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
|
||||
|
||||
async function zhCN() {
|
||||
const i18n = i18next.createInstance();
|
||||
await i18n.init({
|
||||
lng: "zh-CN",
|
||||
fallbackLng: "en",
|
||||
fallbackNS: "translation",
|
||||
resources: {
|
||||
"zh-CN": {
|
||||
translation: { common: { save: "保存" } },
|
||||
"host-metrics": {
|
||||
plugin: { name: "主机指标", description: "查看主机负载" },
|
||||
},
|
||||
"no-description": { plugin: { name: "无描述" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
return i18n.t;
|
||||
}
|
||||
|
||||
describe("pluginName", () => {
|
||||
it("reads the name from the plugin's own namespace", async () => {
|
||||
const t = await zhCN();
|
||||
expect(pluginName(t, { id: "host-metrics", name: "Host Metrics" })).toBe(
|
||||
"主机指标",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the manifest name when the plugin has no strings", async () => {
|
||||
const t = await zhCN();
|
||||
expect(pluginName(t, { id: "not-loaded", name: "Not Loaded" })).toBe(
|
||||
"Not Loaded",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pluginDescription", () => {
|
||||
it("reads the description from the plugin's own namespace", async () => {
|
||||
const t = await zhCN();
|
||||
expect(
|
||||
pluginDescription(t, {
|
||||
id: "host-metrics",
|
||||
description: "See host load",
|
||||
}),
|
||||
).toBe("查看主机负载");
|
||||
});
|
||||
|
||||
it("falls back to the manifest description, or nothing", async () => {
|
||||
const t = await zhCN();
|
||||
expect(
|
||||
pluginDescription(t, { id: "no-description", description: "Plain" }),
|
||||
).toBe("Plain");
|
||||
expect(pluginDescription(t, { id: "not-loaded" })).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||
import type { OnboardingPluginList } from "@/api/plugins-api";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
@@ -186,4 +188,38 @@ describe("PluginsStep", () => {
|
||||
expect(ok).toBe(true);
|
||||
expect(api.applyOnboardingPlugins).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows plugin names and descriptions in the UI language", async () => {
|
||||
const i18n = i18next.createInstance();
|
||||
await i18n.use(initReactI18next).init({
|
||||
lng: "zh-CN",
|
||||
fallbackNS: "translation",
|
||||
resources: {
|
||||
"zh-CN": {
|
||||
translation: {},
|
||||
term: { plugin: { name: "终端", description: "终端插件" } },
|
||||
stats: { plugin: { name: "统计" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<PluginsStep
|
||||
mode="full"
|
||||
isAdmin
|
||||
isDesktop={false}
|
||||
shellReady={false}
|
||||
setCanContinue={() => {}}
|
||||
setBeforeNext={() => {}}
|
||||
/>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
expect(await screen.findByText("终端")).toBeTruthy();
|
||||
expect(screen.getByText("终端插件")).toBeTruthy();
|
||||
expect(screen.getByRole("radiogroup", { name: "终端" })).toBeTruthy();
|
||||
expect(screen.getByText("统计")).toBeTruthy();
|
||||
expect(screen.getByText("stats plugin")).toBeTruthy();
|
||||
expect(screen.getByText("BASE")).toBeTruthy();
|
||||
expect(screen.getByText("base plugin")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -35,9 +35,17 @@ vi.mock("@/api/plugins-api", async (importOriginal) => ({
|
||||
vi.mock("@/components/surface/surface-scope", () => ({
|
||||
useConfirm: () => vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
const i18n = vi.hoisted(() => {
|
||||
const strings: Record<string, string> = {
|
||||
"sample-plugin:plugin.name": "示例",
|
||||
"sample-plugin:plugin.description": "一个示例插件",
|
||||
};
|
||||
return {
|
||||
t: (key: string, options?: { defaultValue?: string }) =>
|
||||
strings[key] ?? options?.defaultValue ?? key,
|
||||
};
|
||||
});
|
||||
vi.mock("react-i18next", () => ({ useTranslation: () => i18n }));
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
|
||||
import { usePluginsManager } from "@/plugins/use-plugins-manager";
|
||||
@@ -69,3 +77,33 @@ it("waits for local consent and sends the reviewed version and capabilities", as
|
||||
{ version: "1.0.0", capabilities: ["kv:own"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("names plugins in the UI language, falling back to the manifest", async () => {
|
||||
api.getPluginRegistry.mockResolvedValueOnce({
|
||||
plugins: [
|
||||
{
|
||||
id: "browse-only",
|
||||
name: "Browse Only",
|
||||
description: "Not installed",
|
||||
author: "someone",
|
||||
category: "Tools",
|
||||
latestVersion: "1.0.0",
|
||||
versions: [],
|
||||
bundled: false,
|
||||
},
|
||||
],
|
||||
managedByServer: true,
|
||||
registry: {},
|
||||
} as never);
|
||||
const { result } = renderHook(() => usePluginsManager());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(
|
||||
result.current.plugins.map(({ name, description }) => ({
|
||||
name,
|
||||
description,
|
||||
})),
|
||||
).toEqual([
|
||||
{ name: "示例", description: "一个示例插件" },
|
||||
{ name: "Browse Only", description: "Not installed" },
|
||||
]);
|
||||
});
|
||||
@@ -33,7 +33,10 @@ vi.mock("@/api/plugins-api", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { defaultValue?: string }) =>
|
||||
options?.defaultValue ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
@@ -75,9 +78,10 @@ describe("syncHostFeatureTabs", () => {
|
||||
"feature:docker",
|
||||
"feature:warpgate",
|
||||
]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))?.label).toBe(
|
||||
"Docker",
|
||||
);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))).toMatchObject({
|
||||
labelKey: "docker:plugin.name",
|
||||
label: "Docker",
|
||||
});
|
||||
expect(getHostEditorSection(hostFeatureTabId("warpgate"))).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user