Fix clamping on value inputs to allow empty while focused

This commit is contained in:
Jack Kingsman
2026-03-29 22:38:06 -07:00
parent f01e91defc
commit 7aa4f76064
6 changed files with 457 additions and 26 deletions

View File

@@ -39,6 +39,7 @@ export function CrackerPanel({
}: CrackerPanelProps) {
const [isRunning, setIsRunning] = useState(false);
const [maxLength, setMaxLength] = useState(6);
const [maxLengthInput, setMaxLengthInput] = useState('6');
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
const [decryptHistorical, setDecryptHistorical] = useState(true);
const [turboMode, setTurboMode] = useState(false);
@@ -191,6 +192,10 @@ export function CrackerPanel({
maxLengthRef.current = maxLength;
}, [maxLength]);
useEffect(() => {
setMaxLengthInput(String(maxLength));
}, [maxLength]);
useEffect(() => {
decryptHistoricalRef.current = decryptHistorical;
}, [decryptHistorical]);
@@ -434,8 +439,25 @@ export function CrackerPanel({
type="number"
min={1}
max={10}
value={maxLength}
onChange={(e) => setMaxLength(Math.min(10, Math.max(1, parseInt(e.target.value) || 6)))}
value={maxLengthInput}
onChange={(e) => {
const nextValue = e.target.value;
setMaxLengthInput(nextValue);
if (nextValue === '') return;
const parsed = Number.parseInt(nextValue, 10);
if (Number.isNaN(parsed)) return;
setMaxLength(Math.min(10, Math.max(1, parsed)));
}}
onBlur={() => {
const parsed = Number.parseInt(maxLengthInput, 10);
const nextValue = Number.isNaN(parsed)
? maxLength
: Math.min(10, Math.max(1, parsed));
setMaxLengthInput(String(nextValue));
if (nextValue !== maxLength) {
setMaxLength(nextValue);
}
}}
className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded"
/>
</div>

View File

@@ -316,6 +316,80 @@ const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries(
CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition])
) as Record<DraftType, CreateIntegrationDefinition>;
function getNumberInputValue(value: unknown, fallback: number): string | number {
if (value === '') return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' && Number.isFinite(value)) return value;
return fallback;
}
function getOptionalNumberInputValue(value: unknown): string | number {
if (value === '') return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' && Number.isFinite(value)) return value;
return '';
}
function parseIntegerInputValue(value: string): number | string {
if (value === '') return '';
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? value : parsed;
}
function parseFloatInputValue(value: string): number | string {
if (value === '') return '';
const parsed = Number.parseFloat(value);
return Number.isNaN(parsed) ? value : parsed;
}
function normalizeIntegrationConfigForSave(
configType: string,
config: Record<string, unknown>
): Record<string, unknown> {
const normalized = { ...config };
if (configType === 'mqtt_private') {
const port = normalized.broker_port;
if (port === '' || port === undefined || port === null) {
normalized.broker_port = 1883;
} else if (typeof port === 'string') {
const parsed = Number.parseInt(port, 10);
normalized.broker_port = Number.isNaN(parsed) ? 1883 : parsed;
}
const topicPrefix = String(normalized.topic_prefix ?? '').trim();
normalized.topic_prefix = topicPrefix || 'meshcore';
}
if (configType === 'mqtt_community') {
const brokerHost = String(normalized.broker_host ?? '').trim();
normalized.broker_host = brokerHost || DEFAULT_COMMUNITY_BROKER_HOST;
const port = normalized.broker_port;
if (port === '' || port === undefined || port === null) {
normalized.broker_port = DEFAULT_COMMUNITY_BROKER_PORT;
} else if (typeof port === 'string') {
const parsed = Number.parseInt(port, 10);
normalized.broker_port = Number.isNaN(parsed) ? DEFAULT_COMMUNITY_BROKER_PORT : parsed;
}
const topicTemplate = String(normalized.topic_template ?? '').trim();
normalized.topic_template = topicTemplate || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE;
}
if (configType === 'map_upload') {
const radius = normalized.geofence_radius_km;
if (radius === '' || radius === undefined || radius === null) {
normalized.geofence_radius_km = 0;
} else if (typeof radius === 'string') {
const parsed = Number.parseFloat(radius);
normalized.geofence_radius_km = Number.isNaN(parsed) ? 0 : parsed;
}
}
return normalized;
}
function isDraftType(value: string): value is DraftType {
return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE;
}
@@ -338,7 +412,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
throw new Error('MeshRank packet topic is required');
}
return {
return normalizeIntegrationConfigForSave('mqtt_community', {
...config,
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
broker_port: DEFAULT_MESHRANK_BROKER_PORT,
@@ -352,7 +426,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
topic_template: topicTemplate,
username: '',
password: '',
};
});
}
if (draftType === 'mqtt_community_letsmesh_us' || draftType === 'mqtt_community_letsmesh_eu') {
@@ -360,7 +434,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
draftType === 'mqtt_community_letsmesh_eu'
? DEFAULT_COMMUNITY_BROKER_HOST_EU
: DEFAULT_COMMUNITY_BROKER_HOST;
return {
return normalizeIntegrationConfigForSave('mqtt_community', {
...config,
broker_host: brokerHost,
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
@@ -372,10 +446,13 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
topic_template: (config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
username: '',
password: '',
};
});
}
return config;
return normalizeIntegrationConfigForSave(
getCreateIntegrationDefinition(draftType).savedType,
config
);
}
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
@@ -649,9 +726,9 @@ function MqttPrivateConfigEditor({
type="number"
min="1"
max="65535"
value={(config.broker_port as number) || 1883}
value={getNumberInputValue(config.broker_port, 1883)}
onChange={(e) =>
onChange({ ...config, broker_port: parseInt(e.target.value, 10) || 1883 })
onChange({ ...config, broker_port: parseIntegerInputValue(e.target.value) })
}
/>
</div>
@@ -709,7 +786,8 @@ function MqttPrivateConfigEditor({
<Input
id="fanout-mqtt-prefix"
type="text"
value={(config.topic_prefix as string) || 'meshcore'}
placeholder="meshcore"
value={(config.topic_prefix as string | undefined) ?? ''}
onChange={(e) => onChange({ ...config, topic_prefix: e.target.value })}
/>
</div>
@@ -745,7 +823,7 @@ function MqttCommunityConfigEditor({
id="fanout-comm-host"
type="text"
placeholder={DEFAULT_COMMUNITY_BROKER_HOST}
value={(config.broker_host as string) || DEFAULT_COMMUNITY_BROKER_HOST}
value={(config.broker_host as string | undefined) ?? ''}
onChange={(e) => onChange({ ...config, broker_host: e.target.value })}
/>
</div>
@@ -756,11 +834,11 @@ function MqttCommunityConfigEditor({
type="number"
min="1"
max="65535"
value={(config.broker_port as number) || DEFAULT_COMMUNITY_BROKER_PORT}
value={getNumberInputValue(config.broker_port, DEFAULT_COMMUNITY_BROKER_PORT)}
onChange={(e) =>
onChange({
...config,
broker_port: parseInt(e.target.value, 10) || DEFAULT_COMMUNITY_BROKER_PORT,
broker_port: parseIntegerInputValue(e.target.value),
})
}
/>
@@ -895,7 +973,8 @@ function MqttCommunityConfigEditor({
<Input
id="fanout-comm-topic-template"
type="text"
value={(config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
placeholder={DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
value={(config.topic_template as string | undefined) ?? ''}
onChange={(e) => onChange({ ...config, topic_template: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
@@ -1215,11 +1294,11 @@ function MapUploadConfigEditor({
min="0"
step="any"
placeholder="e.g. 100"
value={(config.geofence_radius_km as number | undefined) ?? ''}
value={getOptionalNumberInputValue(config.geofence_radius_km)}
onChange={(e) =>
onChange({
...config,
geofence_radius_km: e.target.value === '' ? 0 : parseFloat(e.target.value),
geofence_radius_km: parseFloatInputValue(e.target.value),
})
}
/>
@@ -1997,9 +2076,10 @@ export function SettingsFanoutSection({
if (!currentEditingId) {
throw new Error('Missing fanout config id for update');
}
const editingType = configs.find((cfg) => cfg.id === currentEditingId)?.type ?? '';
const update: Record<string, unknown> = {
name: editName,
config: editConfig,
config: normalizeIntegrationConfigForSave(editingType, editConfig),
scope: editScope,
};
if (enabled !== undefined) update.enabled = enabled;

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { Checkbox } from '../ui/checkbox';
import { PACKET_LEGEND_ITEMS } from '../../utils/visualizerUtils';
import { NODE_LEGEND_ITEMS } from './shared';
@@ -71,6 +72,19 @@ export function VisualizerControls({
onExpandContract,
onClearAndReset,
}: VisualizerControlsProps) {
const [observationWindowInput, setObservationWindowInput] = useState(
String(observationWindowSec)
);
const [pruneWindowInput, setPruneWindowInput] = useState(String(pruneStaleMinutes));
useEffect(() => {
setObservationWindowInput(String(observationWindowSec));
}, [observationWindowSec]);
useEffect(() => {
setPruneWindowInput(String(pruneStaleMinutes));
}, [pruneStaleMinutes]);
return (
<>
{showControls && (
@@ -212,12 +226,25 @@ export function VisualizerControls({
type="number"
min="1"
max="60"
value={observationWindowSec}
onChange={(e) =>
setObservationWindowSec(
Math.max(1, Math.min(60, parseInt(e.target.value, 10) || 1))
)
}
value={observationWindowInput}
onChange={(e) => {
const nextValue = e.target.value;
setObservationWindowInput(nextValue);
if (nextValue === '') return;
const parsed = Number.parseInt(nextValue, 10);
if (Number.isNaN(parsed)) return;
setObservationWindowSec(Math.max(1, Math.min(60, parsed)));
}}
onBlur={() => {
const parsed = Number.parseInt(observationWindowInput, 10);
const nextValue = Number.isNaN(parsed)
? observationWindowSec
: Math.max(1, Math.min(60, parsed));
setObservationWindowInput(String(nextValue));
if (nextValue !== observationWindowSec) {
setObservationWindowSec(nextValue);
}
}}
className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center"
/>
<span className="text-muted-foreground">sec</span>
@@ -247,10 +274,25 @@ export function VisualizerControls({
type="number"
min={1}
max={60}
value={pruneStaleMinutes}
value={pruneWindowInput}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 1 && v <= 60) setPruneStaleMinutes(v);
const nextValue = e.target.value;
setPruneWindowInput(nextValue);
if (nextValue === '') return;
const parsed = Number.parseInt(nextValue, 10);
if (Number.isNaN(parsed)) return;
if (parsed >= 1 && parsed <= 60) setPruneStaleMinutes(parsed);
}}
onBlur={() => {
const parsed = Number.parseInt(pruneWindowInput, 10);
const nextValue =
Number.isNaN(parsed) || parsed < 1 || parsed > 60
? pruneStaleMinutes
: parsed;
setPruneWindowInput(String(nextValue));
if (nextValue !== pruneStaleMinutes) {
setPruneStaleMinutes(nextValue);
}
}}
className="w-14 rounded border border-border bg-background px-2 py-0.5 text-sm"
/>

View File

@@ -0,0 +1,57 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CrackerPanel } from '../components/CrackerPanel';
vi.mock('meshcore-hashtag-cracker', () => ({
GroupTextCracker: class {
isGpuAvailable() {
return false;
}
destroy() {}
setWordlist() {}
abort() {}
},
}));
vi.mock('nosleep.js', () => ({
default: class {
enable() {}
disable() {}
},
}));
vi.mock('../api', () => ({
api: {
getUndecryptedPacketCount: vi.fn(),
},
}));
vi.mock('../components/ui/sonner', () => ({
toast: {
error: vi.fn(),
},
}));
import { api } from '../api';
const mockedApi = vi.mocked(api);
describe('CrackerPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedApi.getUndecryptedPacketCount.mockResolvedValue({ count: 0 });
});
it('allows clearing max length while editing', async () => {
render(<CrackerPanel packets={[]} channels={[]} onChannelCreate={vi.fn()} visible={false} />);
await waitFor(() => {
expect(mockedApi.getUndecryptedPacketCount).toHaveBeenCalled();
});
const maxLengthInput = screen.getByLabelText('Max Length:') as HTMLInputElement;
fireEvent.change(maxLengthInput, { target: { value: '' } });
expect(maxLengthInput.value).toBe('');
});
});

View File

@@ -704,6 +704,75 @@ describe('SettingsFanoutSection', () => {
expect(audienceInput).toHaveValue('');
});
it('existing community MQTT defaults can be cleared while editing and normalize on save', async () => {
const communityConfig: FanoutConfig = {
id: 'comm-1',
type: 'mqtt_community',
name: 'Community Feed',
enabled: false,
config: {
broker_host: 'mqtt-us-v1.letsmesh.net',
broker_port: 443,
transport: 'websockets',
use_tls: true,
tls_verify: true,
auth_mode: 'token',
iata: 'LAX',
email: '',
token_audience: '',
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
sort_order: 0,
created_at: 1000,
};
mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]);
mockedApi.updateFanoutConfig.mockResolvedValue({
...communityConfig,
enabled: true,
});
renderSection();
await waitFor(() => expect(screen.getByText('Community Feed')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
const hostInput = screen.getByLabelText('Broker Host') as HTMLInputElement;
const portInput = screen.getByLabelText('Broker Port') as HTMLInputElement;
const topicTemplateInput = screen.getByLabelText('Packet Topic Template') as HTMLInputElement;
fireEvent.change(hostInput, { target: { value: '' } });
fireEvent.change(portInput, { target: { value: '' } });
fireEvent.change(topicTemplateInput, { target: { value: '' } });
expect(hostInput.value).toBe('');
expect(portInput.value).toBe('');
expect(topicTemplateInput.value).toBe('');
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
await waitFor(() =>
expect(mockedApi.updateFanoutConfig).toHaveBeenCalledWith('comm-1', {
name: 'Community Feed',
config: {
broker_host: 'mqtt-us-v1.letsmesh.net',
broker_port: 443,
transport: 'websockets',
use_tls: true,
tls_verify: true,
auth_mode: 'token',
iata: 'LAX',
email: '',
token_audience: '',
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
enabled: true,
})
);
});
it('community MQTT can be configured for no auth', async () => {
const communityConfig: FanoutConfig = {
id: 'comm-1',
@@ -783,6 +852,65 @@ describe('SettingsFanoutSection', () => {
expect(screen.queryByLabelText('Broker Host')).not.toBeInTheDocument();
});
it('private MQTT fields can be cleared while editing and normalize defaults on create', async () => {
const createdConfig: FanoutConfig = {
id: 'mqtt-private-1',
type: 'mqtt_private',
name: 'Private MQTT 1',
enabled: true,
config: {
broker_host: 'broker.local',
broker_port: 1883,
username: '',
password: '',
use_tls: false,
tls_insecure: false,
topic_prefix: 'meshcore',
},
scope: { messages: 'all', raw_packets: 'all' },
sort_order: 0,
created_at: 2000,
};
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
renderSection();
await openCreateIntegrationDialog();
selectCreateIntegration('Private MQTT');
confirmCreateIntegration();
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('Broker Host'), { target: { value: 'broker.local' } });
const portInput = screen.getByLabelText('Broker Port') as HTMLInputElement;
const prefixInput = screen.getByLabelText('Topic Prefix') as HTMLInputElement;
fireEvent.change(portInput, { target: { value: '' } });
fireEvent.change(prefixInput, { target: { value: '' } });
expect(portInput.value).toBe('');
expect(prefixInput.value).toBe('');
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
await waitFor(() =>
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
type: 'mqtt_private',
name: 'Private MQTT #1',
config: {
broker_host: 'broker.local',
broker_port: 1883,
username: '',
password: '',
use_tls: false,
tls_insecure: false,
topic_prefix: 'meshcore',
},
scope: { messages: 'all', raw_packets: 'all' },
enabled: true,
})
);
});
it('creates MeshRank preset as a regular mqtt_community config', async () => {
const createdConfig: FanoutConfig = {
id: 'comm-meshrank',
@@ -912,6 +1040,57 @@ describe('SettingsFanoutSection', () => {
);
});
it('map upload geofence radius can be cleared while editing and normalizes to zero', async () => {
const createdConfig: FanoutConfig = {
id: 'map-1',
type: 'map_upload',
name: 'Map Upload 1',
enabled: true,
config: {
api_url: '',
dry_run: true,
geofence_enabled: true,
geofence_radius_km: 0,
},
scope: { messages: 'none', raw_packets: 'all' },
sort_order: 0,
created_at: 2000,
};
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
renderSection();
await openCreateIntegrationDialog();
selectCreateIntegration('Map Upload');
confirmCreateIntegration();
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.click(screen.getByText('Enable Geofence'));
const radiusInput = screen.getByLabelText('Radius (km)') as HTMLInputElement;
fireEvent.change(radiusInput, { target: { value: '100' } });
fireEvent.change(radiusInput, { target: { value: '' } });
expect(radiusInput.value).toBe('');
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
await waitFor(() =>
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
type: 'map_upload',
name: 'Map Upload #1',
config: {
api_url: '',
dry_run: true,
geofence_enabled: true,
geofence_radius_km: 0,
},
scope: { messages: 'none', raw_packets: 'all' },
enabled: true,
})
);
});
it('LetsMesh (EU) preset saves the EU broker defaults', async () => {
const createdConfig: FanoutConfig = {
id: 'comm-letsmesh-eu',

View File

@@ -0,0 +1,51 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { VisualizerControls } from '../components/visualizer/VisualizerControls';
describe('VisualizerControls', () => {
it('allows clearing numeric inputs while editing', () => {
render(
<VisualizerControls
showControls
setShowControls={vi.fn()}
showAmbiguousPaths={false}
setShowAmbiguousPaths={vi.fn()}
showAmbiguousNodes={false}
setShowAmbiguousNodes={vi.fn()}
useAdvertPathHints={false}
setUseAdvertPathHints={vi.fn()}
collapseLikelyKnownSiblingRepeaters={false}
setCollapseLikelyKnownSiblingRepeaters={vi.fn()}
splitAmbiguousByTraffic={false}
setSplitAmbiguousByTraffic={vi.fn()}
observationWindowSec={5}
setObservationWindowSec={vi.fn()}
pruneStaleNodes
setPruneStaleNodes={vi.fn()}
pruneStaleMinutes={10}
setPruneStaleMinutes={vi.fn()}
letEmDrift={false}
setLetEmDrift={vi.fn()}
autoOrbit={false}
setAutoOrbit={vi.fn()}
chargeStrength={-100}
setChargeStrength={vi.fn()}
particleSpeedMultiplier={1}
setParticleSpeedMultiplier={vi.fn()}
nodeCount={0}
linkCount={0}
onExpandContract={vi.fn()}
onClearAndReset={vi.fn()}
/>
);
const observationInput = screen.getByLabelText('Ack/echo listen window:') as HTMLInputElement;
const pruneInput = screen.getByLabelText('Window:') as HTMLInputElement;
fireEvent.change(observationInput, { target: { value: '' } });
fireEvent.change(pruneInput, { target: { value: '' } });
expect(observationInput.value).toBe('');
expect(pruneInput.value).toBe('');
});
});