Files
rfcp/RFCP-Iteration10.5-Input-Validation-Hierarchy-UI.md
2026-01-30 19:30:50 +02:00

10 KiB
Raw Blame History

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:

// 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
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.tsx
  • SectorItem.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