diff --git a/app/Modules/Files/Http/Controllers/VirusScanningSettingsController.php b/app/Modules/Files/Http/Controllers/VirusScanningSettingsController.php
index e6743dbc..6979807a 100644
--- a/app/Modules/Files/Http/Controllers/VirusScanningSettingsController.php
+++ b/app/Modules/Files/Http/Controllers/VirusScanningSettingsController.php
@@ -45,9 +45,20 @@ class VirusScanningSettingsController extends Controller
private readonly ActivityLogger $activity,
) {}
- public function edit(): Response
+ public function edit(Request $request): Response
{
return Inertia::render('system/settings/virus-scanning', [
+ // Which half of the screen is open. The connection and the
+ // policies are two different jobs — one is done once when the
+ // scanner is set up, the other is revisited — and a single
+ // column of fields with two Save buttons reads as one form
+ // that saves half of itself.
+ 'tab' => $request->query('tab') === 'options' ? 'options' : 'scanner',
+ // Read from the session here rather than shared as a flash
+ // prop: HandleInertiaRequests shares `success` and `error` and
+ // nothing else, which is why the Test button appeared to do
+ // nothing at all. Same shape the CAPTCHA screen uses.
+ 'test_result' => $request->session()->get('scanner_test_result'),
'enabled' => $this->config->enabled(),
'managed' => $this->config->isManaged(),
'address' => $this->config->isManaged() ? '' : $this->settings->get(Setting::VirusScannerAddress),
diff --git a/resources/js/pages/system/settings/virus-scanning.tsx b/resources/js/pages/system/settings/virus-scanning.tsx
index b4e65eaa..af9edf1b 100644
--- a/resources/js/pages/system/settings/virus-scanning.tsx
+++ b/resources/js/pages/system/settings/virus-scanning.tsx
@@ -1,5 +1,5 @@
import { type BreadcrumbItem } from '@/types';
-import { Head, router, useForm, usePage } from '@inertiajs/react';
+import { Head, Link, router, useForm } from '@inertiajs/react';
import { CheckCircle2, ShieldAlert, TriangleAlert } from 'lucide-react';
import { FormEventHandler } from 'react';
@@ -16,7 +16,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { useTranslation } from '@/hooks/use-translation';
import AppLayout from '@/layouts/app-layout';
+type Tab = 'scanner' | 'options';
+
interface VirusScanningProps {
+ tab: Tab;
+ /** The Test button's answer, carried through the session. */
+ test_result: { ok: boolean; message: string } | null;
enabled: boolean;
/** The scanner is supplied by the platform: no address to set, and no switch. */
managed: boolean;
@@ -35,6 +40,8 @@ interface VirusScanningProps {
}
export default function VirusScanningSettings({
+ tab,
+ test_result,
enabled,
managed,
address,
@@ -46,13 +53,15 @@ export default function VirusScanningSettings({
counts,
}: VirusScanningProps) {
const { t } = useTranslation();
- const testResult = usePage().props.scanner_test_result as { ok: boolean; message: string } | undefined;
const breadcrumbs: BreadcrumbItem[] = [
{ title: t('Settings'), href: '/system/settings' },
{ title: t('Virus scanning'), href: '/system/settings/virus-scanning' },
];
+ // One form behind both tabs, and one Save. The server takes every
+ // field on every save, so switching tabs never loses what was typed on
+ // the other one.
const { data, setData, patch, errors, processing, recentlySuccessful } = useForm({
enabled,
address,
@@ -65,21 +74,23 @@ export default function VirusScanningSettings({
const submit: FormEventHandler = (e) => {
e.preventDefault();
- patch(route('system-settings.virus-scanning.update'));
+ patch(route('system-settings.virus-scanning.update', tab === 'options' ? { tab: 'options' } : {}), { preserveScroll: true });
};
+ const tabs: { key: Tab; label: string }[] = [
+ { key: 'scanner', label: t('Scanner') },
+ { key: 'options', label: t('Options') },
+ ];
+
return (