diff --git a/app/(main)/admin/__tests__/extension-multiline-config.test.tsx b/app/(main)/admin/__tests__/extension-multiline-config.test.tsx new file mode 100644 index 000000000..40501bd9b --- /dev/null +++ b/app/(main)/admin/__tests__/extension-multiline-config.test.tsx @@ -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 }> = []; +const routes: Record = {}; +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(); + 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' } }), + ])); + }); +}); diff --git a/app/(main)/admin/_pages/extension-detail.tsx b/app/(main)/admin/_pages/extension-detail.tsx index 0680c1915..68f6e5c82 100644 --- a/app/(main)/admin/_pages/extension-detail.tsx +++ b/app/(main)/admin/_pages/extension-detail.tsx @@ -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 && ( - {Object.entries(schema).map(([key, field]) => ( + {Object.entries(schema).map(([key, field]) => field.type === 'string' && field.multiline ? ( + setFormValues((prev) => ({ ...prev, [key]: v }))} + /> + ) : ( void; +}) { + const id = useId(); + return ( +
+ + {field.description &&

{field.description}

} +