mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: show a multi-line text box for plugin admin settings that ask for one
This commit is contained in:
@@ -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' } }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user