10 KiB
RFCP Iteration 10.5 — Input Validation + Site/Sector Hierarchy UI
Date: 2025-01-30
Status: Ready for Implementation
Priority: Medium
Estimated Effort: 45-60 minutes
Overview
This iteration addresses two UX issues:
- Bug: Number input fields don't accept manually typed values
- Enhancement: Site/Sector list needs clear visual hierarchy
Part A: Input Validation Fix
Problem
When user clears an input field and tries to type a new value (e.g., "15" or "120"), the input doesn't accept it. The field either:
- Resets to minimum value immediately
- Doesn't allow intermediate states (typing "1" before "15")
Affected Fields:
- Radius (1-100 km)
- Resolution (50-500 m)
- Min Signal (-140 to -50 dBm)
- Heatmap Opacity (30-100%)
- All numeric inputs in site/sector forms
Root Cause
The onChange handler immediately validates and clamps values to min/max range. When user types "1" (intending to type "15"), it gets clamped or rejected before they can finish typing.
Solution
Validate on blur, not on change:
// BEFORE (problematic):
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = Math.max(min, Math.min(max, Number(e.target.value)));
setValue(value);
};
// AFTER (correct):
const [localValue, setLocalValue] = useState(String(value));
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Allow any input while typing
setLocalValue(e.target.value);
};
const handleBlur = () => {
// Validate and clamp only when focus leaves
const parsed = Number(localValue);
if (isNaN(parsed)) {
setLocalValue(String(value)); // Reset to previous valid value
} else {
const clamped = Math.max(min, Math.min(max, parsed));
setLocalValue(String(clamped));
setValue(clamped);
}
};
// Sync local state when prop changes
useEffect(() => {
setLocalValue(String(value));
}, [value]);
Files to Check/Modify
Look for numeric input components in:
src/components/panels/SettingsPanel.tsx(or similar)src/components/ui/NumberInput.tsx(if exists)src/components/ui/Slider.tsx(if has input field)src/components/panels/SiteForm.tsx
Implementation Pattern
If there's a reusable NumberInput component, fix it once. Otherwise, apply the pattern to each input.
Key Points:
- Use local string state for the input value
- Allow free typing (no validation on change)
- Validate and clamp on blur
- Also validate on Enter key press
- Sync local state when external value changes
interface NumberInputProps {
value: number;
onChange: (value: number) => void;
min: number;
max: number;
step?: number;
label?: string;
}
function NumberInput({ value, onChange, min, max, step = 1, label }: NumberInputProps) {
const [localValue, setLocalValue] = useState(String(value));
// Sync when external value changes
useEffect(() => {
setLocalValue(String(value));
}, [value]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalValue(e.target.value);
};
const commitValue = () => {
const parsed = parseFloat(localValue);
if (isNaN(parsed)) {
setLocalValue(String(value));
return;
}
const clamped = Math.max(min, Math.min(max, parsed));
setLocalValue(String(clamped));
if (clamped !== value) {
onChange(clamped);
}
};
const handleBlur = () => commitValue();
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
commitValue();
(e.target as HTMLInputElement).blur();
}
if (e.key === 'Escape') {
setLocalValue(String(value));
(e.target as HTMLInputElement).blur();
}
};
return (
<input
type="text" // Use text, not number, for better control
inputMode="numeric" // Shows numeric keyboard on mobile
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
);
}
Part B: Site/Sector Hierarchy UI
Problem
Current UI shows flat list with confusing structure:
Station-1 2 sectors
Station-1-Beta Edit + Sector ×
Station-1-Beta Edit + Sector ×
Issues:
- Sector names duplicate site name ("Station-1-Beta")
- No clear visual hierarchy
- "+ Sector" button on each sector (should be on site only)
- Can't easily distinguish site from sector
Desired UI
📍 Station-1 [Edit Site] [+ Sector] [×]
├─ α Alpha 140° · 2100MHz · 43dBm [Edit] [×]
└─ β Beta 260° · 2100MHz · 43dBm [Edit] [×]
📍 Station-2 [Edit Site] [+ Sector] [×]
└─ α Alpha 90° · 1800MHz · 40dBm [Edit] [×]
Design Specifications
Site Row:
- Icon: 📍 or radio tower icon
- Name: Bold, larger font
- Sector count badge: "2 sectors" (subtle)
- Actions: [Edit Site] [+ Sector] [Delete Site]
- Background: Slightly different shade
- Click to expand/collapse sectors
Sector Row:
- Indented (padding-left: 24px or similar)
- Greek letter prefix: α, β, γ, δ, ε, ζ (visual identifier)
- Sector name: "Alpha", "Beta" (without site prefix)
- Key params inline: Azimuth · Frequency · Power
- Actions: [Edit] [Delete]
- Tree line connector: ├─ and └─ (optional, CSS pseudo-elements)
Greek Letters Mapping:
const GREEK_LETTERS = ['α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ'];
// Alpha=α, Beta=β, Gamma=γ, Delta=δ, Epsilon=ε, Zeta=ζ, Eta=η, Theta=θ
Component Structure
SiteList
├── SiteItem (for each site)
│ ├── SiteHeader (name, actions, expand/collapse)
│ └── SectorList (when expanded)
│ └── SectorItem (for each sector)
│ ├── SectorInfo (greek letter, name, params)
│ └── SectorActions (edit, delete)
File to Modify
/opt/rfcp/frontend/src/components/panels/SiteList.tsx
Or if there are sub-components:
SiteItem.tsxSectorItem.tsx
Implementation Notes
1. Data Structure (existing):
interface Site {
id: string;
name: string;
lat: number;
lon: number;
sectors: Sector[];
}
interface Sector {
id: string;
name: string; // "Alpha", "Beta", etc.
azimuth: number;
frequency: number;
power: number;
// ... other props
}
2. Greek Letter Helper:
const SECTOR_GREEK: Record<string, string> = {
'Alpha': 'α',
'Beta': 'β',
'Gamma': 'γ',
'Delta': 'δ',
'Epsilon': 'ε',
'Zeta': 'ζ',
'Eta': 'η',
'Theta': 'θ',
};
const getGreekLetter = (sectorName: string): string => {
return SECTOR_GREEK[sectorName] || '•';
};
3. CSS for Hierarchy:
.site-item {
border-left: 3px solid transparent;
}
.site-item.selected {
border-left-color: #7c3aed;
background: rgba(124, 58, 237, 0.1);
}
.site-header {
display: flex;
align-items: center;
padding: 8px 12px;
font-weight: 600;
}
.sector-list {
padding-left: 16px;
border-left: 1px solid rgba(255, 255, 255, 0.1);
margin-left: 12px;
}
.sector-item {
display: flex;
align-items: center;
padding: 6px 12px;
font-size: 0.9em;
}
.sector-greek {
width: 20px;
color: #7c3aed;
font-weight: 600;
}
.sector-params {
color: rgba(255, 255, 255, 0.6);
font-size: 0.85em;
margin-left: 8px;
}
4. Expand/Collapse (optional):
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpand = (siteId: string) => {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(siteId)) {
next.delete(siteId);
} else {
next.add(siteId);
}
return next;
});
};
Testing Checklist
Part A: Input Validation
- Can type "15" in Radius field (not rejected after "1")
- Can type "120" in Resolution field
- Value validates on blur (focus out)
- Value validates on Enter key
- Escape key reverts to previous value
- Invalid input (letters) reverts to previous value
- Values outside range are clamped on blur
- Slider still works with arrow buttons
- Mobile: numeric keyboard appears
Part B: Hierarchy UI
- Sites show with icon and bold name
- Sectors indented under their site
- Greek letters (α, β, γ) show for sectors
- Sector names without site prefix ("Alpha" not "Station-1-Alpha")
- Key params visible inline (azimuth, frequency, power)
- "+ Sector" button only on site row
- Edit/Delete buttons work correctly
- Selected site/sector highlighted
- Multiple sites display correctly
- Empty state (no sites) handled
Visual Reference
Before:
┌─────────────────────────────────────────────┐
│ Station-1 2 sectors │
│ ┌─────────────────────────────────────┐ │
│ │ Station-1-Beta Edit +Sector × │ │
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ Station-1-Beta Edit +Sector × │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
After:
┌─────────────────────────────────────────────┐
│ 📍 Station-1 [Edit] [+] [×] │
│ │ │
│ ├─ α Alpha 140° · 2100MHz · 43dBm [✎][×] │
│ └─ β Beta 260° · 2100MHz · 43dBm [✎][×] │
│ │
│ 📍 Station-2 [Edit] [+] [×] │
│ │ │
│ └─ α Alpha 90° · 1800MHz · 40dBm [✎][×] │
└─────────────────────────────────────────────┘
Reference
- Previous iteration: RFCP-Iteration10.4-Fix-Stadia-Maps-401.md
- Site/Sector data model:
/opt/rfcp/frontend/src/types/ - Current SiteList:
/opt/rfcp/frontend/src/components/panels/SiteList.tsx