@mytec: start iter10.5
This commit is contained in:
395
RFCP-Iteration10.5-Input-Validation-Hierarchy-UI.md
Normal file
395
RFCP-Iteration10.5-Input-Validation-Hierarchy-UI.md
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
# 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`
|
||||||
Reference in New Issue
Block a user