From 11b1ec2fa54892335cb15ad4bad1c8d20bdcd439 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:24:32 +0200 Subject: [PATCH] feat: show plugin setting labels in the user's language when the plugin translates them --- .../plugins-settings-labels.test.tsx | 37 +++++++++++++++++++ components/settings/plugins-settings.tsx | 15 ++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 components/settings/__tests__/plugins-settings-labels.test.tsx diff --git a/components/settings/__tests__/plugins-settings-labels.test.tsx b/components/settings/__tests__/plugins-settings-labels.test.tsx new file mode 100644 index 000000000..5b31a7617 --- /dev/null +++ b/components/settings/__tests__/plugins-settings-labels.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, useLocale: () => 'de' })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ demoMode: false }) })); + +import { PluginsSettings } from '../plugins-settings'; +import { usePluginStore } from '@/stores/plugin-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import type { InstalledPlugin } from '@/lib/plugin-types'; + +const PLUGIN = { + id: 'p', name: 'P', version: '1.0.0', author: 'a', description: 'd', type: 'hook', permissions: [], entrypoint: 'index.js', + enabled: true, status: 'running', managed: true, settings: {}, + settingsSchema: { + mode: { type: 'select', label: 'settings.mode', description: 'settings.mode_help', default: 'fast', options: ['fast', 'careful'] }, + plain: { type: 'boolean', label: 'Plain label', default: false }, + }, + locales: { de: { 'settings.mode': 'Modus', 'settings.mode_help': 'Wie gearbeitet wird', fast: 'Schnell' } }, +} as unknown as InstalledPlugin; + +describe('plugin settings labels', () => { + it('shows labels, descriptions and options from the plugin\'s own translations', () => { + usePolicyStore.setState({ loaded: true, isFeatureEnabled: () => true, isPluginForceEnabled: () => false } as never); + usePluginStore.setState({ plugins: [PLUGIN], initialized: true, initializePlugins: vi.fn() } as never); + + render(); + fireEvent.click(screen.getByText('P')); + + expect(screen.getByText('Modus')).toBeInTheDocument(); + expect(screen.getByText('Wie gearbeitet wird')).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Schnell' })).toBeInTheDocument(); + // No translation: shown as written. + expect(screen.getByRole('option', { name: 'careful' })).toBeInTheDocument(); + expect(screen.getByText('Plain label')).toBeInTheDocument(); + }); +}); diff --git a/components/settings/plugins-settings.tsx b/components/settings/plugins-settings.tsx index 6f5a49b55..9188fdd79 100644 --- a/components/settings/plugins-settings.tsx +++ b/components/settings/plugins-settings.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect, useRef } from 'react'; -import { useTranslations } from 'next-intl'; +import { useLocale, useTranslations } from 'next-intl'; import { usePluginStore } from '@/stores/plugin-store'; import { usePolicyStore } from '@/stores/policy-store'; import { useConfig } from '@/hooks/use-config'; @@ -14,6 +14,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { PluginPermissionList } from '@/components/plugins/plugin-permission-list'; import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types'; import { effectiveSettings } from '@/lib/plugin-settings'; +import { lookup } from '@/lib/plugin-sandbox/plugin-i18n'; import { forgetCredentials, listConnectedAccounts, type ConnectedAccount } from '@/lib/plugin-sandbox/connect-dialog'; const STATUS_COLORS: Record = { @@ -211,6 +212,7 @@ interface PluginCardProps { function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onToggleExpand, onToggle, onRemove, onUpdateSettings }: PluginCardProps) { const t = useTranslations('plugins.settings'); + const locale = useLocale(); const isManaged = Boolean(plugin.managed); const awaiting = !isManaged && plugin.approvalStatus === 'pending'; const denied = !isManaged && plugin.approvalStatus === 'denied'; @@ -308,6 +310,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onTo key={key} fieldKey={key} schema={schema} + translate={(text) => (plugin.locales ? lookup(plugin.locales, locale, text) ?? text : text)} value={effectiveSettings(plugin)[key]} onChange={(value) => onUpdateSettings({ [key]: value })} /> @@ -381,12 +384,18 @@ function ConnectedAccounts({ pluginId }: { pluginId: string }) { interface PluginSettingFieldProps { fieldKey: string; schema: SettingFieldSchema; + /** + * A label, description or option that names a key in the plugin's own + * translations (manifest `locales`) is shown translated; other text as is. + */ + translate?: (text: string) => string; value: unknown; onChange: (value: unknown) => void; } -function PluginSettingField({ fieldKey, schema, value, onChange }: PluginSettingFieldProps) { +function PluginSettingField({ fieldKey, schema: raw, value, onChange, translate = (text) => text }: PluginSettingFieldProps) { const inputId = `plugin-setting-${fieldKey}`; + const schema = { ...raw, label: translate(raw.label), ...(raw.description ? { description: translate(raw.description) } : {}) }; switch (schema.type) { case 'boolean': return ( @@ -413,7 +422,7 @@ function PluginSettingField({ fieldKey, schema, value, onChange }: PluginSetting className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground" > {(Array.isArray(schema.options) ? schema.options : []).map(opt => ( - + ))}