ソースを参照

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

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