feat: show a multi-line text box for plugin admin settings that ask for one

This commit is contained in:
Linus Rath
2026-10-30 00:17:32 +01:00
parent cf0eb03c4a
commit 80476d1ad2
4 changed files with 99 additions and 2 deletions
@@ -0,0 +1,55 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
// A plugin setting declared `multiline` (the assistant's instructions) gets a
// text box that keeps line breaks; other strings stay one-line inputs.
const calls: Array<{ url: string; method: string; body: Record<string, unknown> }> = [];
const routes: Record<string, unknown> = {};
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: vi.fn(async (url: string, init?: RequestInit) => {
const method = init?.method ?? 'GET';
calls.push({ url, method, body: JSON.parse(String(init?.body ?? '{}')) });
const data = method === 'GET' ? routes[url.split('?')[0]] ?? {} : { ok: true };
return new Response(JSON.stringify(data), { status: 200, headers: { 'content-type': 'application/json' } });
}),
withBasePath: (p: string) => p,
}));
vi.mock('next/link', () => ({ default: ({ children }: { children: React.ReactNode }) => children }));
vi.mock('../_pages/use-extension-icons', () => ({ useExtensionIcons: () => () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
vi.mock('@/components/plugins/plugin-iframe-slot', () => ({ PluginIframeSlot: () => null }));
import { ExtensionDetailPage } from '../_pages/extension-detail';
beforeEach(() => {
calls.length = 0;
routes['/api/admin/plugins'] = [{
id: 'ai-assistant', name: 'AI Writing Assistant', version: '2.1.1', enabled: true, author: 'x', description: '', permissions: [],
configSchema: {
model: { type: 'string', label: 'Model' },
instructions: { type: 'string', label: 'Instructions for the assistant', multiline: true, description: 'Added to every request.' },
},
}];
routes['/api/admin/plugins/ai-assistant/config'] = { instructions: 'Write in British English.\nSign off with "Kind regards".' };
});
afterEach(() => {
useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null });
});
describe('plugin settings with several lines', () => {
it('shows a text box for a multiline setting and saves its line breaks', async () => {
render(<ExtensionDetailPage kind="plugin" id="ai-assistant" />);
const box = await screen.findByRole('textbox', { name: 'Instructions for the assistant' });
expect(box.tagName).toBe('TEXTAREA');
await waitFor(() => expect((box as HTMLTextAreaElement).value).toBe('Write in British English.\nSign off with "Kind regards".'));
expect(screen.getByRole('textbox', { name: 'Model' }).tagName).toBe('INPUT');
fireEvent.change(box, { target: { value: 'Line one\nLine two' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(calls.filter((c) => c.method !== 'GET')).toEqual([
expect.objectContaining({ url: '/api/admin/plugins/ai-assistant/config', body: { key: 'instructions', value: 'Line one\nLine two' } }),
]));
});
});
+40 -2
View File
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useId, useMemo, useState } from 'react';
import { Puzzle, SwatchBook, Loader2, Eye, EyeOff, Lock, RotateCcw, Trash2, Shield, ShieldAlert, Store, Upload } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { adminPath } from '@/lib/admin/admin-nav';
@@ -31,6 +31,8 @@ export interface ConfigField {
default?: unknown;
placeholder?: string;
options?: { label: string; value: string }[];
/** A string that takes several lines (instructions, templates): a full-width text box. */
multiline?: boolean;
}
/** A plugin or theme as /api/admin/plugins and /api/admin/themes list it. */
@@ -421,7 +423,14 @@ export function ExtensionDetailPage({ kind, id }: Props) {
{/* The plugin's own settings come last: they are the long part. */}
{kind === 'plugin' && hasSchema && schema && (
<SettingsSection id="settings" title="Settings" description="What this plugin reads from the server. Saved for every user.">
{Object.entries(schema).map(([key, field]) => (
{Object.entries(schema).map(([key, field]) => field.type === 'string' && field.multiline ? (
<MultilineConfigItem
key={key}
field={field}
value={formValues[key] ?? ''}
onChange={(v) => setFormValues((prev) => ({ ...prev, [key]: v }))}
/>
) : (
<SettingItem
key={key}
label={field.required ? `${field.label} *` : field.label}
@@ -510,6 +519,35 @@ function ConfigFieldControl({ field, value, stored, revealed, onReveal, onChange
);
}
/**
* A setting that takes several lines: label and description on top, a
* full-width text box under them, as wide as the section.
*/
function MultilineConfigItem({ field, value, onChange }: {
field: ConfigField;
value: string;
onChange: (value: string) => void;
}) {
const id = useId();
return (
<div data-search-label={field.label} className="py-3 border-b border-border last:border-0">
<label htmlFor={id} className="text-sm font-medium text-foreground">
{field.required ? `${field.label} *` : field.label}
</label>
{field.description && <p id={`${id}-desc`} className="text-xs text-muted-foreground mt-1">{field.description}</p>}
<textarea
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder || ''}
aria-describedby={field.description ? `${id}-desc` : undefined}
rows={5}
className={cn(inputClass, 'mt-2 block h-auto min-h-24 w-full resize-y py-2 leading-relaxed')}
/>
</div>
);
}
/**
* The plugin's own `admin-plugin-page` slot, if it offers one. Sandboxed
* plugins ship a React component under `slots['admin-plugin-page']` and the
+2
View File
@@ -25,6 +25,8 @@ export interface PluginConfigField {
default?: unknown;
placeholder?: string;
options?: { label: string; value: string }[];
/** `type: 'string'` only: the admin form shows a full-width text box. */
multiline?: boolean;
}
/**
+2
View File
@@ -307,6 +307,8 @@ export interface ConfigFieldSchema {
default?: unknown;
placeholder?: string;
options?: { label: string; value: string }[];
/** `type: 'string'` only: the admin form shows a full-width text box. */
multiline?: boolean;
}
function validateConfigSchema(raw: unknown, errors: string[]): Record<string, ConfigFieldSchema> | undefined {