소스 검색

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.
nima1024m 14 시간 전
부모
커밋
51ffba5961
2개의 변경된 파일77개의 추가작업 그리고 7개의 파일을 삭제
  1. 19 7
      frontend/src/pages/xray/balancers/BalancerFormModal.tsx
  2. 58 0
      frontend/src/test/balancer-form-modal.test.tsx

+ 19 - 7
frontend/src/pages/xray/balancers/BalancerFormModal.tsx

@@ -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

+ 58 - 0
frontend/src/test/balancer-form-modal.test.tsx

@@ -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();
+  });
+});