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
+4 -2
View File
@@ -3,10 +3,12 @@
"version": "0.21.8", "version": "0.21.8",
"date": "2026-07-28", "date": "2026-07-28",
"en": [ "en": [
"Microwave bands are recognised: 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm and above, plus 33cm and the 2190m/630m/560m low bands. A 10 GHz QSO used to come back with NO band at all — so it was logged without one, counted in no award slot, and exported without a BAND field. Band pickers, statistics and award band columns follow." "Microwave bands are recognised: 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm and above, plus 33cm and the 2190m/630m/560m low bands. A 10 GHz QSO used to come back with NO band at all — so it was logged without one, counted in no award slot, and exported without a BAND field. Band pickers, statistics and award band columns follow.",
"Port fields can be cleared. Deleting the contents put the default straight back, so entering a different port meant overwriting it digit by digit. Fixed on the cluster editor, where it was reported, and in the eight other places with the same fault (CAT, amplifier, antenna, rotator, database, SMTP)."
], ],
"fr": [ "fr": [
"Les bandes hyperfréquences sont reconnues : 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm et au-delà, ainsi que 33cm et les bandes basses 2190m/630m/560m. Un QSO à 10 GHz revenait sans AUCUNE bande — donc enregistré sans bande, compté dans aucun diplôme, et exporté sans champ BAND. Les listes de bandes, les statistiques et les colonnes de diplômes suivent." "Les bandes hyperfréquences sont reconnues : 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm et au-delà, ainsi que 33cm et les bandes basses 2190m/630m/560m. Un QSO à 10 GHz revenait sans AUCUNE bande — donc enregistré sans bande, compté dans aucun diplôme, et exporté sans champ BAND. Les listes de bandes, les statistiques et les colonnes de diplômes suivent.",
"Les champs de port peuvent être vidés. Effacer le contenu réinscrivait aussitôt la valeur par défaut, si bien qu'entrer un autre port obligeait à l'écraser chiffre par chiffre. Corrigé dans l'éditeur de cluster, où c'était signalé, et dans les huit autres endroits présentant le même défaut (CAT, amplificateur, antenne, rotator, base de données, SMTP)."
] ]
}, },
{ {
+61 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight, ChevronDown, ChevronRight,
@@ -2358,8 +2358,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('cat.port')}</Label> <Label>{t('cat.port')}</Label>
<Input type="number" value={catCfg.flex_port || 4992} <PortInput value={catCfg.flex_port || 4992}
onChange={(e) => setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} /> onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} /> <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>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('cat.port')}</Label> <Label>{t('cat.port')}</Label>
<Input type="number" value={catCfg.tci_port || 40001} <PortInput value={catCfg.tci_port || 40001}
onChange={(e) => setCatCfg((s) => ({ ...s, tci_port: parseInt(e.target.value) || 40001 }))} /> onChange={(n) => setCatCfg((s) => ({ ...s, tci_port: n }))} fallback={40001} />
</div> </div>
<p className="col-span-2 text-xs text-muted-foreground"> <p className="col-span-2 text-xs text-muted-foreground">
{t('cat.tciHint')} {t('cat.tciHint')}
@@ -2654,10 +2654,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>TCP port</Label> <Label>TCP port</Label>
<Input <PortInput
type="number" min={1} max={65535}
value={ultrabeam.port} 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" className="font-mono"
/> />
</div> </div>
@@ -2943,8 +2942,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>TCP port</Label> <Label>TCP port</Label>
<Input type="number" min={1} max={65535} value={amp.port} <PortInput value={amp.port}
onChange={(e) => patchAmp(i, { port: parseInt(e.target.value) || 9008 })} className="font-mono" /> onChange={(n) => patchAmp(i, { port: n })} fallback={9008} className="font-mono" />
</div> </div>
</div> </div>
)} )}
@@ -3114,10 +3113,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label> <Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
<Input <PortInput
type="number" min={1} max={65535}
value={rotator.port} 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" className="font-mono"
/> />
</div> </div>
@@ -4678,7 +4676,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Label className="text-sm">{t('db.host')}</Label> <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 })} /> <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> <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> <Label className="text-sm">{t('db.database')}</Label>
<Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} /> <Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
<Label className="text-sm">{t('db.user')}</Label> <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 })} /> <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> <Label className="text-sm">Port / encryption</Label>
<div className="flex gap-2 items-center"> <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 })}> <Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent> <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 // ClusterServerEditor edits one row of cluster_servers. Init commands are
// free-form (one per line); the backend strips blanks and "//" comments. // free-form (one per line); the backend strips blanks and "//" comments.
interface ClusterEditorProps { interface ClusterEditorProps {
@@ -5389,7 +5434,7 @@ function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Port</Label> <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>
<div className="space-y-1"> <div className="space-y-1">
<Label>Login callsign (optional)</Label> <Label>Login callsign (optional)</Label>