From 6191b749b6283ce2a749a75155cef69f0720e779 Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:32:55 -0500 Subject: [PATCH] feat(dashboard): schema-driven plugin config form Render an editable config form in the Plugins config modal for any plugin that exposes a configSchema (the backend already returns it). Each field maps to an input by type: string->text, secret->password (masked), number->number, boolean->toggle, enum->select; with title/description labels and defaults as placeholders. Saves via the existing PUT /plugins/:id/config. Reuses the existing modal/form styles and i18n keys (no new locale strings). Makes the libretranslate URL + API key (and any future plugin config) editable from the dashboard. --- dashboard/src/pages/Plugins.css | 12 ++++ dashboard/src/pages/Plugins.tsx | 106 +++++++++++++++++++++++++++++++- dashboard/src/services/api.ts | 18 ++++++ 3 files changed, 134 insertions(+), 2 deletions(-) diff --git a/dashboard/src/pages/Plugins.css b/dashboard/src/pages/Plugins.css index 5a791a94..f73abada 100644 --- a/dashboard/src/pages/Plugins.css +++ b/dashboard/src/pages/Plugins.css @@ -436,6 +436,18 @@ box-shadow: 0 0 0 3px rgba(37, 211, 102, 0.1); } +.config-form .form-group small { + display: block; + margin-top: 0.4rem; + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.4; +} + +.config-form .form-group .required-mark { + color: var(--danger, #e5484d); +} + .toggle-group { display: flex; align-items: center; diff --git a/dashboard/src/pages/Plugins.tsx b/dashboard/src/pages/Plugins.tsx index 9e6260f3..bf039966 100644 --- a/dashboard/src/pages/Plugins.tsx +++ b/dashboard/src/pages/Plugins.tsx @@ -71,6 +71,8 @@ export default function Plugins() { browserArgs: '--no-sandbox --disable-gpu', }); const [savingConfig, setSavingConfig] = useState(false); + // Values for a schema-driven (non-engine) plugin's config form, keyed by configSchema property. + const [schemaConfig, setSchemaConfig] = useState>({}); const refetchAll = () => { void queryClient.invalidateQueries({ queryKey: queryKeys.plugins }); @@ -112,9 +114,32 @@ export default function Plugins() { const handleOpenConfig = (plugin: Plugin) => { setConfigPlugin(plugin); + // Seed the schema form from the plugin's saved config, falling back to each field's default. + if (plugin.configSchema?.properties) { + const initial: Record = {}; + for (const [key, field] of Object.entries(plugin.configSchema.properties)) { + initial[key] = plugin.config[key] ?? field.default ?? (field.type === 'boolean' ? false : ''); + } + setSchemaConfig(initial); + } setShowConfigModal(true); }; + const handleSaveSchemaConfig = async () => { + if (!configPlugin) return; + setSavingConfig(true); + try { + await pluginsApi.updateConfig(configPlugin.id, schemaConfig); + void queryClient.invalidateQueries({ queryKey: queryKeys.plugins }); + toast.success(t('plugins.toasts.savedTitle'), t('plugins.toasts.savedDesc')); + setShowConfigModal(false); + } catch (err) { + toast.error(t('plugins.toasts.saveFailed'), err instanceof Error ? err.message : t('common.unknownError')); + } finally { + setSavingConfig(false); + } + }; + const handleSaveConfig = async () => { setSavingConfig(true); try { @@ -401,6 +426,79 @@ export default function Plugins() { + ) : configPlugin.configSchema && Object.keys(configPlugin.configSchema.properties).length > 0 ? ( +
+ {Object.entries(configPlugin.configSchema.properties).map(([key, field]) => { + const value = schemaConfig[key]; + const label = field.title || key; + + if (field.type === 'boolean') { + return ( +
+
+ + {field.description && {field.description}} +
+ +
+ ); + } + + if (field.enum && field.enum.length > 0) { + return ( +
+ + + {field.description && {field.description}} +
+ ); + } + + const inputType = field.type === 'number' ? 'number' : field.secret ? 'password' : 'text'; + return ( +
+ + + setSchemaConfig({ + ...schemaConfig, + [key]: + field.type === 'number' + ? e.target.value === '' + ? '' + : Number(e.target.value) + : e.target.value, + }) + } + /> + {field.description && {field.description}} +
+ ); + })} +
) : (
@@ -413,11 +511,15 @@ export default function Plugins() { - {configPlugin.type === 'engine' && ( + {configPlugin.type === 'engine' ? ( - )} + ) : configPlugin.configSchema && Object.keys(configPlugin.configSchema.properties).length > 0 ? ( + + ) : null}
diff --git a/dashboard/src/services/api.ts b/dashboard/src/services/api.ts index 00176449..34f8f8b5 100644 --- a/dashboard/src/services/api.ts +++ b/dashboard/src/services/api.ts @@ -557,6 +557,22 @@ export const settingsApi = { // Plugin Types // ============================================================================= +/** Field definition within a plugin's config schema (mirrors the backend PluginConfigSchema). */ +export interface PluginConfigField { + type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + title?: string; + description?: string; + default?: unknown; + enum?: unknown[]; + required?: boolean; + secret?: boolean; +} + +export interface PluginConfigSchema { + type: 'object'; + properties: Record; +} + export interface Plugin { id: string; name: string; @@ -568,6 +584,8 @@ export interface Plugin { config: Record; builtIn: boolean; provides: string[]; + /** Declared config fields, when the plugin exposes a schema (drives the dashboard config form). */ + configSchema?: PluginConfigSchema; loadedAt?: string; enabledAt?: string; error?: string;