fix: a port field could not be cleared

Every port box was parseInt(e.target.value) || <default>. Delete the contents
and parseInt gives NaN, so the default is written straight back: the digits
reappear under the cursor and the only way to enter a different port is to
overwrite it character by character. Reported on the cluster editor; the same
line existed in eight other places.

One PortInput component now holds the raw TEXT as its state and only tells the
parent about a value that is actually a port. An empty box stays empty while you
type; on blur an invalid one falls back to the default, so nothing is ever saved
as 0 or NaN. It adopts a value arriving from the backend only while the operator
has not started typing — re-syncing mid-edit is the behaviour being fixed.

This is the controlled-input trap the project notes already warn about: keep the
raw text in local state and derive the stored value from it.
This commit is contained in:
2026-07-28 15:25:21 +02:00
parent d303dee768
commit b18db7acfc
2 changed files with 65 additions and 18 deletions
+61 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
@@ -2358,8 +2358,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="space-y-1">
<Label>{t('cat.port')}</Label>
<Input type="number" value={catCfg.flex_port || 4992}
onChange={(e) => setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} />
<PortInput value={catCfg.flex_port || 4992}
onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} />
</div>
<div className="col-span-2">
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
@@ -2482,8 +2482,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="space-y-1">
<Label>{t('cat.port')}</Label>
<Input type="number" value={catCfg.tci_port || 40001}
onChange={(e) => setCatCfg((s) => ({ ...s, tci_port: parseInt(e.target.value) || 40001 }))} />
<PortInput value={catCfg.tci_port || 40001}
onChange={(n) => setCatCfg((s) => ({ ...s, tci_port: n }))} fallback={40001} />
</div>
<p className="col-span-2 text-xs text-muted-foreground">
{t('cat.tciHint')}
@@ -2654,10 +2654,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="space-y-1">
<Label>TCP port</Label>
<Input
type="number" min={1} max={65535}
<PortInput
value={ultrabeam.port}
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))}
onChange={(n) => setUltrabeam((s) => ({ ...s, port: n }))} fallback={23}
className="font-mono"
/>
</div>
@@ -2943,8 +2942,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="space-y-1">
<Label>TCP port</Label>
<Input type="number" min={1} max={65535} value={amp.port}
onChange={(e) => patchAmp(i, { port: parseInt(e.target.value) || 9008 })} className="font-mono" />
<PortInput value={amp.port}
onChange={(n) => patchAmp(i, { port: n })} fallback={9008} className="font-mono" />
</div>
</div>
)}
@@ -3114,10 +3113,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="space-y-1">
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
<Input
type="number" min={1} max={65535}
<PortInput
value={rotator.port}
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : isARCO ? 4001 : 12000) }))}
onChange={(n) => setRotator((s) => ({ ...s, port: n }))} fallback={isRG ? 9006 : isARCO ? 4001 : 12000}
className="font-mono"
/>
</div>
@@ -4678,7 +4676,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Label className="text-sm">{t('db.host')}</Label>
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
<Label className="text-sm">{t('db.port')}</Label>
<Input type="number" className="h-8 w-28 font-mono" value={mysqlCfg.port} onChange={(e) => setMysqlField({ port: parseInt(e.target.value, 10) || 0 })} />
<PortInput className="h-8 w-28 font-mono" value={mysqlCfg.port} fallback={3306} onChange={(n) => setMysqlField({ port: n })} />
<Label className="text-sm">{t('db.database')}</Label>
<Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
<Label className="text-sm">{t('db.user')}</Label>
@@ -5154,7 +5152,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Input className="h-8" placeholder="ex5.mail.ovh.net" value={emailCfg.smtp_host} onChange={(e) => setEmailField({ smtp_host: e.target.value })} />
<Label className="text-sm">Port / encryption</Label>
<div className="flex gap-2 items-center">
<Input type="number" className="h-8 w-24 font-mono" value={emailCfg.smtp_port} onChange={(e) => setEmailField({ smtp_port: parseInt(e.target.value, 10) || 0 })} />
<PortInput className="h-8 w-24 font-mono" value={emailCfg.smtp_port} fallback={587} onChange={(n) => setEmailField({ smtp_port: n })} />
<Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -5358,6 +5356,53 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
);
}
// PortInput — a TCP port field you can actually clear.
//
// Every port box was written as `parseInt(e.target.value) || <default>`. Delete
// the contents and parseInt gives NaN, so the default is written straight back:
// the digits reappear under the cursor and the only way to enter a different
// port is to overwrite character by character. Reported on the cluster editor;
// the same line existed in eight other places.
//
// The raw TEXT is the state here, and the parent is only told about a value that
// is actually a port. An empty box stays empty while you type; on blur it falls
// back to the last good value, so nothing is ever saved as 0 or NaN.
function PortInput({ value, onChange, fallback, className, min = 1, max = 65535 }: {
value: number; onChange: (n: number) => void; fallback: number; className?: string; min?: number; max?: number;
}) {
const [text, setText] = useState(value ? String(value) : '');
const typed = useRef(false);
// Only adopt the incoming value while the operator has NOT started typing —
// settings arrive from the backend after mount, and re-syncing mid-edit is
// precisely the behaviour being fixed.
useEffect(() => {
if (!typed.current) setText(value ? String(value) : '');
}, [value]);
return (
<Input
type="text"
inputMode="numeric"
className={className}
value={text}
onChange={(e) => {
typed.current = true;
const digits = e.target.value.replace(/[^0-9]/g, '').slice(0, 5);
setText(digits);
const n = parseInt(digits, 10);
if (n >= min && n <= max) onChange(n);
}}
onBlur={() => {
typed.current = false;
const n = parseInt(text, 10);
if (!(n >= min && n <= max)) {
setText(String(fallback));
onChange(fallback);
}
}}
/>
);
}
// ClusterServerEditor edits one row of cluster_servers. Init commands are
// free-form (one per line); the backend strips blanks and "//" comments.
interface ClusterEditorProps {
@@ -5389,7 +5434,7 @@ function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
</div>
<div className="space-y-1">
<Label>Port</Label>
<Input type="number" min={1} max={65535} className="font-mono" value={s.port} onChange={(e) => update({ port: parseInt(e.target.value) || 7300 })} />
<PortInput className="font-mono" value={s.port} fallback={7300} onChange={(n) => update({ port: n })} />
</div>
<div className="space-y-1">
<Label>Login callsign (optional)</Label>