396 lines
10 KiB
Markdown
396 lines
10 KiB
Markdown
# 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:
|
||
|
||
1. **Bug:** Number input fields don't accept manually typed values
|
||
2. **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:**
|
||
|
||
```typescript
|
||
// 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:**
|
||
1. Use local string state for the input value
|
||
2. Allow free typing (no validation on change)
|
||
3. Validate and clamp on blur
|
||
4. Also validate on Enter key press
|
||
5. Sync local state when external value changes
|
||
|
||
```typescript
|
||
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:**
|
||
```typescript
|
||
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.tsx`
|
||
- `SectorItem.tsx`
|
||
|
||
### Implementation Notes
|
||
|
||
**1. Data Structure (existing):**
|
||
```typescript
|
||
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:**
|
||
```typescript
|
||
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:**
|
||
```css
|
||
.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):**
|
||
```typescript
|
||
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`
|