mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-31 04:12:13 +03:00
refactor(ui): share one onNumber handler for numeric setting inputs (#6127)
* refactor(ui): share one onNumber handler for numeric setting inputs The Number(v) || 0 idiom in InputNumber onChange handlers is the root pattern behind the cleared-port bug (#6121): AntD reports a cleared field as null, and || 0 turns that into a stored zero or a min-clamp. The port fields got an inline null-guard; the other sixteen numeric settings kept the idiom, so every new field is a chance to reintroduce the bug. Extract the guard into onNumber(apply): null, empty and NaN change events are ignored so a cleared field snaps back to its stored value on blur, and numeric events pass through unchanged. Convert all sixteen sites in the settings and xray pages. Two sites keep their deliberate different semantics: smtpPort falls back to 587 on clear, and the Telegram notify interval clamps through Math.max. For the non-port fields this changes clearing from storing 0 to keeping the stored value; zero remains reachable by typing it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(ui): fold the remaining hand-rolled numeric guards into onNumber From review: ObservatorySettingsTab's sampling field hand-rolled the same ignore-null semantic and smtpPort kept a fallback-to-587 on clear that nothing documents as intentional and that silently overwrites a configured non-standard port — both now go through the shared helper, leaving the Telegram interval clamp as the one deliberate exception. Also from review: narrow the helper to numbers only (no stringMode input exists in the repo, and the string branch codified a guarantee the number-typed callback cannot honour), soften the docblock to describe behavior rather than promise prevention, add a GeneralTab component test covering the clear-vs-typed-zero semantics, and assert the blur snap-back in both settings tests so a display/state desync cannot ship unnoticed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
|
||||
import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
|
||||
@@ -64,7 +65,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} description={t('pages.settings.smtpPortDesc')}>
|
||||
<InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ smtpPort: Number(v) || 587 })} />
|
||||
onChange={onNumber((v) => updateSetting({ smtpPort: v }))} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpUsername')} description={t('pages.settings.smtpUsernameDesc')}>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { HttpUtil, LanguageManager } from '@/utils';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
@@ -170,7 +171,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} description={t('pages.settings.panelPortDesc')}>
|
||||
<InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
|
||||
onChange={(v) => { if (v != null) updateSetting({ webPort: v }); }} />
|
||||
onChange={onNumber((v) => updateSetting({ webPort: v }))} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.panelUrlPath')} description={t('pages.settings.panelUrlPathDesc')}>
|
||||
@@ -179,7 +180,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} description={t('pages.settings.sessionMaxAgeDesc')}>
|
||||
<InputNumber value={allSetting.sessionMaxAge} min={60} max={525600} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ sessionMaxAge: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
@@ -208,7 +209,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
|
||||
<InputNumber value={allSetting.pageSize} min={0} max={1000} step={5} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ pageSize: v }))} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
|
||||
@@ -234,11 +235,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
|
||||
<InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ expireDiff: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ expireDiff: v }))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
|
||||
<InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ trafficDiff: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ trafficDiff: v }))} />
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
@@ -308,7 +309,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.port')}>
|
||||
<InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
|
||||
onChange={(v) => { if (v != null) updateSetting({ ldapPort: v }); }} />
|
||||
onChange={onNumber((v) => updateSetting({ ldapPort: v }))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
|
||||
<Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
|
||||
@@ -387,15 +388,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')}>
|
||||
<InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ ldapDefaultTotalGB: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')}>
|
||||
<InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ ldapDefaultExpiryDays: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')}>
|
||||
<InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ ldapDefaultLimitIP: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))} />
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { GoRegexInput } from '@/components/form';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
@@ -279,11 +280,11 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||
<div className="format-settings">
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
|
||||
<InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
|
||||
onChange={(v) => setMuxField('concurrency', Number(v) || 0)} />
|
||||
onChange={onNumber((v) => setMuxField('concurrency', v))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpConcurrency')}>
|
||||
<InputNumber value={muxObj.xudpConcurrency} min={-1} max={1024} style={{ width: '100%' }}
|
||||
onChange={(v) => setMuxField('xudpConcurrency', Number(v) || 0)} />
|
||||
onChange={onNumber((v) => setMuxField('xudpConcurrency', v))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpUdp443')}>
|
||||
<Select
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined,
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { RemarkTemplateField } from '@/components/form';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
@@ -57,7 +58,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subPort')} description={t('pages.settings.subPortDesc')}>
|
||||
<InputNumber value={allSetting.subPort} min={1} max={65535} style={{ width: '100%' }}
|
||||
onChange={(v) => { if (v != null) updateSetting({ subPort: v }); }} />
|
||||
onChange={onNumber((v) => updateSetting({ subPort: v }))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subPath')} description={t('pages.settings.subPathDesc')}>
|
||||
<Input
|
||||
@@ -106,7 +107,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
|
||||
<InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ subUpdates: v }))} />
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
|
||||
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import {
|
||||
BurstObservatorySchema,
|
||||
@@ -195,7 +196,7 @@ export default function ObservatorySettingsTab({
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={burst.pingConfig.sampling}
|
||||
onChange={(v) => patchPingConfig({ sampling: typeof v === 'number' ? v : burst.pingConfig.sampling })}
|
||||
onChange={onNumber((v) => patchPingConfig({ sampling: v }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||
@@ -311,7 +312,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
min={0}
|
||||
step={60}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => setDnsField('serveExpiredTTL', Number(v) || 0)}
|
||||
onChange={onNumber((v) => setDnsField('serveExpiredTTL', v))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Button, Dropdown, Input, InputNumber, Space } from 'antd';
|
||||
import { MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
|
||||
import { addrFor, domainsFor, expectedIPsFor } from './helpers';
|
||||
import type { DnsServerValue } from './DnsServerModal';
|
||||
|
||||
@@ -113,7 +115,7 @@ export function useFakednsColumns({
|
||||
aria-label={t('pages.xray.fakedns.poolSize')}
|
||||
min={1}
|
||||
size="small"
|
||||
onChange={(v) => updateFakednsField(index, 'poolSize', Number(v) || 0)}
|
||||
onChange={onNumber((v) => updateFakednsField(index, 'poolSize', v))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import PromptModal from '@/components/feedback/PromptModal';
|
||||
import TextModal from '@/components/feedback/TextModal';
|
||||
|
||||
@@ -626,14 +627,14 @@ export default function OutboundsTab({
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={intervalHours}
|
||||
onChange={(v) => setIntervalHM(Number(v) || 0, intervalMinutes)}
|
||||
onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))}
|
||||
style={{ width: 80 }}
|
||||
/> {t('pages.xray.outboundSub.hours')}
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={59}
|
||||
value={intervalMinutes}
|
||||
onChange={(v) => setIntervalHM(intervalHours, Number(v) || 0)}
|
||||
onChange={onNumber((v) => setIntervalHM(intervalHours, v))}
|
||||
style={{ width: 80 }}
|
||||
/> {t('pages.xray.outboundSub.minutes')}
|
||||
</Space>
|
||||
|
||||
40
frontend/src/test/general-tab.test.tsx
Normal file
40
frontend/src/test/general-tab.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import GeneralTab from '@/pages/settings/GeneralTab';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
describe('GeneralTab', () => {
|
||||
it('keeps the stored page size when the field is cleared', () => {
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const pageSizeInput = screen.getByDisplayValue('25');
|
||||
fireEvent.change(pageSizeInput, { target: { value: '' } });
|
||||
fireEvent.blur(pageSizeInput);
|
||||
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
expect((pageSizeInput as HTMLInputElement).value).toBe('25');
|
||||
});
|
||||
|
||||
it('forwards typed page sizes unchanged, zero included', () => {
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('25'), { target: { value: '0' } });
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith({ pageSize: 0 });
|
||||
});
|
||||
});
|
||||
23
frontend/src/test/on-number.test.ts
Normal file
23
frontend/src/test/on-number.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
|
||||
describe('onNumber', () => {
|
||||
it('forwards numeric values, including zero and negatives', () => {
|
||||
const apply = vi.fn();
|
||||
const handler = onNumber(apply);
|
||||
handler(8443);
|
||||
handler(0);
|
||||
handler(-1);
|
||||
expect(apply.mock.calls).toEqual([[8443], [0], [-1]]);
|
||||
});
|
||||
|
||||
it('ignores cleared events instead of writing a synthetic value', () => {
|
||||
const apply = vi.fn();
|
||||
const handler = onNumber(apply);
|
||||
handler(null);
|
||||
handler(undefined);
|
||||
handler(NaN);
|
||||
expect(apply).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ describe('SubscriptionGeneralTab', () => {
|
||||
fireEvent.blur(portInput);
|
||||
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
expect((portInput as HTMLInputElement).value).toBe('2096');
|
||||
});
|
||||
|
||||
it('forwards typed subscription ports unchanged', () => {
|
||||
|
||||
16
frontend/src/utils/onNumber.ts
Normal file
16
frontend/src/utils/onNumber.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Wraps an Ant Design InputNumber change handler with the shared
|
||||
* cleared-field semantic: null and undefined change events (a cleared or
|
||||
* unparsable field) are ignored, leaving the stored value in place — the
|
||||
* input snaps back on blur — while real numbers pass through unchanged.
|
||||
* Number-only by design: not for `stringMode` inputs, whose whole point is
|
||||
* to avoid the IEEE-754 round trip this signature would force.
|
||||
*/
|
||||
export function onNumber(
|
||||
apply: (value: number) => void,
|
||||
): (value: number | null | undefined) => void {
|
||||
return (value) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return;
|
||||
apply(value);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user