mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-07 21:34:21 +00:00
fix(balancers): defer validation errors until touched or save (#5626)
The Add Balancer modal parsed its empty initial state through BalancerFormSchema on mount and bound Form.Item validateStatus/help directly to the result, so "Tag is required" and "Pick at least one outbound" rendered the moment the modal opened, before any user input. Gate the inline errors behind per-field touched tracking plus a submit-attempted flag, and drop the disabled Create button so a save attempt surfaces the errors (matching RuleFormModal). The existing key-based remount in BalancersTab resets the flags on each open. Add a regression test asserting no errors on open and errors only after a save attempt.
This commit is contained in:
@@ -68,10 +68,14 @@ export default function BalancerFormModal({
|
||||
}: BalancerFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<FormState>(() => initialState(balancer));
|
||||
const [touched, setTouched] = useState<Partial<Record<keyof FormState, boolean>>>({});
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const isEdit = balancer != null;
|
||||
|
||||
const update = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||
setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true }));
|
||||
setState((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const parsed = useMemo(
|
||||
() => BalancerFormSchema.safeParse(state),
|
||||
@@ -89,8 +93,17 @@ export default function BalancerFormModal({
|
||||
return map;
|
||||
}, [parsed, t]);
|
||||
|
||||
const showTagIssue = submitAttempted || !!touched.tag;
|
||||
const showSelectorIssue = submitAttempted || !!touched.selector;
|
||||
const tagError = showTagIssue ? issues.tag : '';
|
||||
const selectorError = showSelectorIssue ? issues.selector : '';
|
||||
const showDuplicate = showTagIssue && duplicateTag;
|
||||
|
||||
function submit() {
|
||||
if (!parsed.success || duplicateTag) return;
|
||||
if (!parsed.success || duplicateTag) {
|
||||
setSubmitAttempted(true);
|
||||
return;
|
||||
}
|
||||
const values = { ...parsed.data };
|
||||
if (values.strategy !== 'leastLoad') delete values.settings;
|
||||
onConfirm(values);
|
||||
@@ -128,7 +141,6 @@ export default function BalancerFormModal({
|
||||
title={title}
|
||||
okText={okText}
|
||||
cancelText={t('close')}
|
||||
okButtonProps={{ disabled: !parsed.success || duplicateTag }}
|
||||
mask={{ closable: false }}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
@@ -137,8 +149,8 @@ export default function BalancerFormModal({
|
||||
<Form.Item
|
||||
label={t('pages.xray.balancer.tag')}
|
||||
required
|
||||
validateStatus={issues.tag ? 'error' : duplicateTag ? 'warning' : ''}
|
||||
help={issues.tag || (duplicateTag ? t('pages.xray.balancer.tagDuplicate') : '')}
|
||||
validateStatus={tagError ? 'error' : showDuplicate ? 'warning' : ''}
|
||||
help={tagError || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')}
|
||||
hasFeedback
|
||||
>
|
||||
<Input
|
||||
@@ -157,8 +169,8 @@ export default function BalancerFormModal({
|
||||
<Form.Item
|
||||
label={t('pages.xray.balancer.selector')}
|
||||
required
|
||||
validateStatus={issues.selector ? 'error' : ''}
|
||||
help={issues.selector || ''}
|
||||
validateStatus={selectorError ? 'error' : ''}
|
||||
help={selectorError || ''}
|
||||
hasFeedback
|
||||
>
|
||||
<Select
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
import BalancerFormModal from '@/pages/xray/balancers/BalancerFormModal';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function renderModal(onConfirm = vi.fn()) {
|
||||
renderWithProviders(
|
||||
<BalancerFormModal
|
||||
open
|
||||
balancer={null}
|
||||
outboundTags={['proxy', 'direct']}
|
||||
otherTags={['existing']}
|
||||
onClose={() => {}}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
return { onConfirm };
|
||||
}
|
||||
|
||||
function erroredItemCount(): number {
|
||||
return document.querySelectorAll('.ant-form-item-has-error').length;
|
||||
}
|
||||
|
||||
function explainText(): string {
|
||||
return Array.from(document.querySelectorAll('.ant-form-item-explain'))
|
||||
.map((el) => (el.textContent ?? '').trim())
|
||||
.join(' | ');
|
||||
}
|
||||
|
||||
function createButton(): HTMLElement {
|
||||
const btn = document.querySelector('.ant-modal-footer .ant-btn-primary');
|
||||
if (!btn) throw new Error('Create button not found');
|
||||
return btn as HTMLElement;
|
||||
}
|
||||
|
||||
describe('BalancerFormModal', () => {
|
||||
it('shows no validation errors when freshly opened in add mode', () => {
|
||||
renderModal();
|
||||
expect(document.querySelector('.ant-modal')).toBeTruthy();
|
||||
expect(erroredItemCount()).toBe(0);
|
||||
expect(explainText()).not.toContain('Tag is required');
|
||||
expect(explainText()).not.toContain('Pick at least one outbound');
|
||||
expect(createButton().hasAttribute('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('reveals required-field errors only after a save attempt, without confirming', () => {
|
||||
const { onConfirm } = renderModal();
|
||||
expect(erroredItemCount()).toBe(0);
|
||||
|
||||
fireEvent.click(createButton());
|
||||
|
||||
expect(erroredItemCount()).toBe(2);
|
||||
expect(explainText()).toContain('Tag is required');
|
||||
expect(explainText()).toContain('Pick at least one outbound');
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user