fix(inbounds): surface form validation errors (#6084)

* inbounds: surface form validation errors

React Hook Form validation previously returned early without showing why an inbound save was blocked. Report the first field error and switch to the corresponding form tab so operators can correct it.

* Fix inbound form tab error navigation

Improve react-hook-form error traversal so validation stops on real `FieldError` leaves (detected by `type`) instead of any object with a `message`. This makes Save reliably jump to the tab containing the first invalid field and show the specific error, avoiding the previous generic/ambiguous invalid-state handling.

---------

Co-authored-by: sonic <sonic@linux.do>
This commit is contained in:
sonic
2026-08-16 05:09:48 +08:00
committed by GitHub
parent 930a0ed59d
commit 3fa88adbd7
2 changed files with 157 additions and 5 deletions
@@ -127,6 +127,42 @@ function isValidShareAddrInput(value: string): boolean {
return SHARE_ADDR_HOSTNAME_RE.test(v); return SHARE_ADDR_HOSTNAME_RE.test(v);
} }
interface RhfValidationIssue {
path: PropertyKey[];
message: string;
}
function firstRhfValidationIssue(
value: unknown,
path: PropertyKey[] = [],
): RhfValidationIssue | null {
if (!value || typeof value !== 'object') return null;
const record = value as Record<string, unknown>;
// `type` is what marks a react-hook-form leaf FieldError; anything else is a group.
if ('type' in record) {
return { path, message: typeof record.message === 'string' ? record.message : '' };
}
for (const key of Object.keys(record)) {
const issue = firstRhfValidationIssue(record[key], [...path, key]);
if (issue) return issue;
}
return null;
}
function tabForValidationPath(path: PropertyKey[]): string {
if (path[0] === 'settings') return 'protocol';
if (path[0] === 'sniffing') return 'sniffing';
if (path[0] === 'streamSettings') {
if (
path[1] === 'security'
|| path[1] === 'realitySettings'
|| path[1] === 'tlsSettings'
) return 'security';
return 'stream';
}
return 'basic';
}
interface InboundFormModalProps { interface InboundFormModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -195,6 +231,7 @@ export default function InboundFormModal({
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [scanResult, setScanResult] = useState<RealityScanResult | null>(null); const [scanResult, setScanResult] = useState<RealityScanResult | null>(null);
const [activeTab, setActiveTab] = useState('basic');
const { const {
fallbacks, fallbacks,
fallbackChildOptions, fallbackChildOptions,
@@ -356,6 +393,7 @@ export default function InboundFormModal({
: buildAddModeValues(); : buildAddModeValues();
methods.reset(initial); methods.reset(initial);
setScanResult(null); setScanResult(null);
setActiveTab('basic');
const initialTag = (initial.tag ?? '') as string; const initialTag = (initial.tag ?? '') as string;
autoTagRef.current = isAutoInboundTag(initialTag, { autoTagRef.current = isAutoInboundTag(initialTag, {
port: initial.port ?? 0, port: initial.port ?? 0,
@@ -460,8 +498,7 @@ export default function InboundFormModal({
/* eslint-disable-next-line react-hooks/exhaustive-deps */ /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [mode, methods]); }, [mode, methods]);
const submit = async () => { const saveValues = async () => {
if (!(await methods.trigger())) return;
/* /*
* getValues() returns the entire form store, including settings.clients and * getValues() returns the entire form store, including settings.clients and
* settings.fallbacks which have no bound field (clients are managed via the * settings.fallbacks which have no bound field (clients are managed via the
@@ -503,6 +540,17 @@ export default function InboundFormModal({
} }
}; };
/*
* Field errors render inline, but every tab is force-rendered, so an error on
* a hidden tab looks like a dead Save button — jump to it and say what broke.
*/
const submit = methods.handleSubmit(saveValues, (errors) => {
const issue = firstRhfValidationIssue(errors);
if (!issue) return;
setActiveTab(tabForValidationPath(issue.path));
messageApi.error(formatInboundIssue(issue, methods.getValues(), t));
});
const title = mode === 'edit' const title = mode === 'edit'
? t('pages.inbounds.modifyInbound') ? t('pages.inbounds.modifyInbound')
: t('pages.inbounds.addInbound'); : t('pages.inbounds.addInbound');
@@ -961,7 +1009,7 @@ export default function InboundFormModal({
wrapperCol={{ sm: { span: 14 } }} wrapperCol={{ sm: { span: 14 } }}
labelWrap labelWrap
> >
<Tabs items={[ <Tabs activeKey={activeTab} onChange={setActiveTab} items={[
{ key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab, forceRender: true }, { key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab, forceRender: true },
...(([ ...(([
Protocols.VLESS, Protocols.VLESS,
+106 -2
View File
@@ -1,9 +1,10 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { screen, act, render, cleanup } from '@testing-library/react'; import { screen, act, render, cleanup, fireEvent, waitFor } from '@testing-library/react';
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal'; import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
import { DBInbound } from '@/models/dbinbound'; import { DBInbound } from '@/models/dbinbound';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { HttpUtil } from '@/utils';
import { import {
renderWithProviders, renderWithProviders,
fieldLabels, fieldLabels,
@@ -11,6 +12,19 @@ import {
chooseSelectOption, chooseSelectOption,
} from './test-utils'; } from './test-utils';
const { messageError } = vi.hoisted(() => ({ messageError: vi.fn() }));
vi.mock('antd', async (importOriginal) => {
const actual = await importOriginal<typeof import('antd')>();
return {
...actual,
message: {
...actual.message,
useMessage: () => [{ error: messageError }, null],
},
};
});
function renderModal() { function renderModal() {
return renderWithProviders( return renderWithProviders(
<InboundFormModal <InboundFormModal
@@ -25,6 +39,63 @@ function renderModal() {
); );
} }
function primaryButton(): HTMLElement {
const button = document.querySelector('.ant-modal-footer .ant-btn-primary');
if (!button) throw new Error('Primary modal button not found');
return button as HTMLElement;
}
function cloneLikeVlessInbound(target: string) {
return new DBInbound({
id: 42,
port: 41234,
listen: '',
protocol: 'vless',
remark: 'source clone',
enable: false,
settings: {
clients: [],
decryption: 'none',
encryption: 'none',
fallbacks: [],
},
streamSettings: {
network: 'tcp',
security: 'reality',
tcpSettings: { header: { type: 'none' } },
realitySettings: {
target,
serverNames: ['example.com'],
privateKey: 'test-private-key',
shortIds: ['abcd'],
settings: {
publicKey: 'test-public-key',
fingerprint: 'chrome',
spiderX: '/',
},
},
},
sniffing: { enabled: false },
nodeId: null,
shareAddrStrategy: 'listen',
shareAddr: '',
});
}
function renderCloneLikeEdit(dbInbound: DBInbound) {
renderWithProviders(
<InboundFormModal
open
mode="edit"
dbInbound={dbInbound}
dbInbounds={[dbInbound]}
availableNodes={[]}
onClose={() => {}}
onSaved={() => {}}
/>,
);
}
describe('InboundFormModal', () => { describe('InboundFormModal', () => {
it('renders add mode without crashing', () => { it('renders add mode without crashing', () => {
renderModal(); renderModal();
@@ -141,4 +212,37 @@ describe('InboundFormModal', () => {
expect(strategyItem('Node address')).toBeTruthy(); expect(strategyItem('Node address')).toBeTruthy();
expect(strategyItem('Inbound listen')).toBeFalsy(); expect(strategyItem('Inbound listen')).toBeFalsy();
}); });
it('surfaces a Reality validation error and switches to its tab', async () => {
const post = vi.mocked(HttpUtil.post);
post.mockClear();
messageError.mockClear();
renderCloneLikeEdit(cloneLikeVlessInbound('example.com'));
fireEvent.click(primaryButton());
await waitFor(() => {
const securityTab = screen.getByRole('tab', { name: 'Security' });
expect(securityTab.getAttribute('aria-selected')).toBe('true');
});
expect(messageError).toHaveBeenCalledWith(
expect.stringContaining('REALITY target must include a port'),
);
expect(post).not.toHaveBeenCalled();
});
it('submits a valid clone-like Reality inbound', async () => {
const post = vi.mocked(HttpUtil.post);
post.mockClear();
renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
fireEvent.click(primaryButton());
await waitFor(() => {
expect(post).toHaveBeenCalledWith(
'/panel/api/inbounds/update/42',
expect.objectContaining({ enable: false, port: 41234, protocol: 'vless' }),
);
});
});
}); });