From 56ceb81ea582b5aff9e6499e1023c21cbd86e9da Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Wed, 10 Jun 2026 09:47:25 -0600 Subject: [PATCH] Add searchable dropdowns for Granite Loader columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RESPONSIBLE_TEAM, EQUIP_STATUS, and EQUIPMENT_CLASS now show searchable dropdown selectors in both the Bulk Defaults section and per-row inline editing. Type to filter options, use arrow keys to navigate, Enter to select. Picklist values extracted from docs/Team_Device Loader.xlsx reference sheets. Per-row cells remain click-to-edit for all columns — picklist columns show the SearchableSelect, free-text columns show a plain input. --- frontend/src/components/LoaderModal.js | 56 +++++-- frontend/src/components/SearchableSelect.js | 148 +++++++++++++++++++ frontend/src/utils/graniteLoaderPicklists.js | 65 ++++++++ 3 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/SearchableSelect.js create mode 100644 frontend/src/utils/graniteLoaderPicklists.js diff --git a/frontend/src/components/LoaderModal.js b/frontend/src/components/LoaderModal.js index 2f8eb2c..867694e 100644 --- a/frontend/src/components/LoaderModal.js +++ b/frontend/src/components/LoaderModal.js @@ -15,6 +15,8 @@ import { getColumnsByGroup, } from '../utils/graniteLoaderConfig'; import { generateLoaderXlsx, generateFilename } from '../utils/graniteLoaderExport'; +import { COLUMN_PICKLISTS } from '../utils/graniteLoaderPicklists'; +import SearchableSelect from './SearchableSelect'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; @@ -477,12 +479,22 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) { - setBulkDefault(col.id, e.target.value)} - placeholder={`Default for all rows`} - /> + {COLUMN_PICKLISTS[col.id] ? ( + setBulkDefault(col.id, val)} + placeholder={`Default for all rows`} + autoFocus={false} + /> + ) : ( + setBulkDefault(col.id, e.target.value)} + placeholder={`Default for all rows`} + /> + )} ))} @@ -527,14 +539,30 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) { onClick={() => !isEditing && startEdit(rowIdx, col.id)} > {isEditing ? ( - setEditValue(e.target.value)} - onBlur={commitEdit} - onKeyDown={e => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingCell(null); }} - autoFocus - /> + COLUMN_PICKLISTS[col.id] ? ( + { + setOverrides(prev => ({ + ...prev, + [rowIdx]: { ...(prev[rowIdx] || {}), [col.id]: val }, + })); + setEditingCell(null); + }} + onClose={() => setEditingCell(null)} + autoFocus + /> + ) : ( + setEditValue(e.target.value)} + onBlur={commitEdit} + onKeyDown={e => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingCell(null); }} + autoFocus + /> + ) ) : (
{hasOverride && } diff --git a/frontend/src/components/SearchableSelect.js b/frontend/src/components/SearchableSelect.js new file mode 100644 index 0000000..718a84f --- /dev/null +++ b/frontend/src/components/SearchableSelect.js @@ -0,0 +1,148 @@ +/** + * SearchableSelect — Inline searchable dropdown for the Granite Loader Sheet. + * + * Supports: + * - Static options (small lists like EQUIP_STATUS) + * - Large searchable lists (RESPONSIBLE_TEAM, SITE_NAME, EQUIP_TEMPLATE) + * - Keyboard navigation (arrow keys, enter, escape) + * - Typeahead filtering + * - Portal-free (renders inline to avoid z-index issues in table cells) + */ + +import React, { useState, useRef, useEffect, useCallback } from 'react'; + +const DROPDOWN_STYLE = { + position: 'absolute', + top: '100%', + left: 0, + right: 0, + zIndex: 100, + background: '#0F172A', + border: '1px solid #7C3AED', + borderRadius: '0 0 0.375rem 0.375rem', + maxHeight: '180px', + overflowY: 'auto', + boxShadow: '0 8px 24px rgba(0, 0, 0, 0.5)', +}; + +const OPTION_STYLE = { + padding: '0.3rem 0.6rem', + fontSize: '0.7rem', + color: '#E2E8F0', + cursor: 'pointer', + fontFamily: "'JetBrains Mono', monospace", +}; + +const OPTION_HIGHLIGHT = { + ...OPTION_STYLE, + background: 'rgba(124, 58, 237, 0.2)', +}; + +export default function SearchableSelect({ value, options, onChange, onClose, placeholder, autoFocus }) { + const [filter, setFilter] = useState(value || ''); + const [highlightIdx, setHighlightIdx] = useState(-1); + const [isOpen, setIsOpen] = useState(true); + const inputRef = useRef(null); + const listRef = useRef(null); + + useEffect(() => { + if (autoFocus && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [autoFocus]); + + const filtered = useCallback(() => { + if (!filter.trim()) return options.slice(0, 50); + const lower = filter.toLowerCase(); + return options.filter(o => o.toLowerCase().includes(lower)).slice(0, 50); + }, [filter, options]); + + const filteredOptions = filtered(); + + // Scroll highlighted item into view + useEffect(() => { + if (highlightIdx >= 0 && listRef.current) { + const el = listRef.current.children[highlightIdx]; + if (el) el.scrollIntoView({ block: 'nearest' }); + } + }, [highlightIdx]); + + const handleKeyDown = (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightIdx(prev => Math.min(prev + 1, filteredOptions.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightIdx(prev => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (highlightIdx >= 0 && filteredOptions[highlightIdx]) { + onChange(filteredOptions[highlightIdx]); + } else if (filter.trim()) { + onChange(filter.trim()); + } + if (onClose) onClose(); + } else if (e.key === 'Escape') { + if (onClose) onClose(); + } else if (e.key === 'Tab') { + // Accept current value on tab + if (highlightIdx >= 0 && filteredOptions[highlightIdx]) { + onChange(filteredOptions[highlightIdx]); + } else if (filter.trim()) { + onChange(filter.trim()); + } + if (onClose) onClose(); + } + }; + + const handleSelect = (opt) => { + onChange(opt); + if (onClose) onClose(); + }; + + return ( +
+ { setFilter(e.target.value); setHighlightIdx(-1); setIsOpen(true); }} + onKeyDown={handleKeyDown} + onBlur={() => { + // Delay close so click on option can fire + setTimeout(() => { + if (filter.trim() && filter.trim() !== value) { + onChange(filter.trim()); + } + if (onClose) onClose(); + }, 150); + }} + placeholder={placeholder || 'Search...'} + /> + {isOpen && filteredOptions.length > 0 && ( +
+ {filteredOptions.map((opt, i) => ( +
setHighlightIdx(i)} + onMouseDown={(e) => { e.preventDefault(); handleSelect(opt); }} + > + {opt} +
+ ))} + {filteredOptions.length === 50 && ( +
+ Type to filter more... +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/utils/graniteLoaderPicklists.js b/frontend/src/utils/graniteLoaderPicklists.js new file mode 100644 index 0000000..4bfa714 --- /dev/null +++ b/frontend/src/utils/graniteLoaderPicklists.js @@ -0,0 +1,65 @@ +/** + * Granite Loader Sheet picklist values. + * Extracted from docs/Team_Device Loader.xlsx reference sheets. + * These values are used for searchable dropdowns in the LoaderModal. + */ + +export const RESPONSIBLE_TEAMS = [ + 'AE-LESS-ENDS', 'AE-TI-LESS-PIES', 'APVS-PCDS-DIGITAL-ACCESS', 'APVS-PCDS-DIGITAL-IDENTITY', + 'APVS-UNKNOWN', 'ARCHIVED', 'AVWO-NON-CHARTER', 'AVWO-UNKNOWN', 'CARD-ABANDONED-UNKNOWN', + 'CARD-UNKNOWN', 'CTEC-LAB-SQUAD', 'CTEC-UNKNOWN', 'FOE-FIELD OPS', 'FOE-FIELD OPS-ROC', + 'ISP-NDC-CENTENNIAL', 'ISP-NDC-CHARLOTTE', 'ISP-NDC-COUDERSPORT', 'ISP-NDC-SIMPSONVILLE', + 'ISP-UNKNOWN', 'IT', 'IT-DSSS-EDP', 'IT-SA-OPS', 'IT-SOC', 'MTG-CORE-ENG', + 'MTG-WSTC-SYSTEM CERTIFICATION', 'NEO-UNKNOWN', 'NOC-METRICS-DATA WAREHOUSE', 'NON-CHARTER', + 'NTS-AEO-ACCESS-ENG', 'NTS-AEO-ACCESS-OPS', 'NTS-AEO-INTELDEV', 'NTS-AEO-STEAM', + 'NTS-AVOC-AIA-ANALYTICS-OPS', 'NTS-AVOC-AIA-TOOLS', 'NTS-AVOC-AIA-TOOLS-CHANGEAUTO', + 'NTS-AVOC-AIA-TOOLS-PERFMANAG', 'NTS-AVOC-CVO', 'NTS-AVOC-OPSINTEL', + 'NTS-AVOC-OPSINTEL-NSIGHTS', 'NTS-AVOC-RC-KM', 'NTS-AVOC-RC-TICKETING', + 'NTS-AVOC-SCM-ISS', 'NTS-AVOC-SCM-SAPO', 'NTS-AVOC-VCO', + 'NTS-CPE-IRCS-DASDNS', 'NTS-CPE-IRCS-DATAENG', 'NTS-CPE-IRCS-DCMS', + 'NTS-CPE-IRCS-DEVICE-AUTOMATION', 'NTS-CPE-IRCS-DEVICE-OPERATIONS', + 'NTS-CPE-IRCS-INTERNETRELIABILITY', 'NTS-CPE-IRCS-SCP-OPERATIONS', + 'NTS-CPE-WIFIHWDEV-CPEHW-AUTOMATION', 'NTS-CVWO-VOICE-LAB', 'NTS-CVWO-VOICE-OPS', + 'NTS-CVWO-WIRELESS-HMNO-WHO', 'NTS-CVWO-WIRELESS-RANEA', 'NTS-CVWO-WIRELESS-WNBO', + 'NTS-CVWO-WIRELESS-WNO-MWF', 'NTS-CVWO-WIRELESS-WOP-WCO', 'NTS-ISP-NDC', 'NTS-ISP-OPS', + 'NTS-NEO-BB-IP', 'NTS-NEO-BB-OPTICAL', 'NTS-NEO-CORE-IP', 'NTS-NEO-CORE-OPTICAL', + 'NTS-NEO-IP-MGMT', 'NTS-NEO-OPSENG-LAB', 'NTS-NEO-OPSENG-TOOLS', + 'PRDCT-VSO-VDE-ADV-DEV', 'PRDCT-VSO-VDE-ADV-FOCUS', 'PRDCT-VSO-VDE-ADV-IPOIS', + 'PRDCT-VSO-VDE-ADV-PQI', 'PRDCT-VSO-VDE-ADV-SRTA', 'PRDCT-VSO-VDE-CI', + 'PRDCT-VSO-VDE-ENT', 'PRDCT-VSO-VDE-IPVENG', 'PRDCT-VSO-VDE-VCDT', + 'PRDCT-VSO-VDE-VOD-CMS', 'PRDCT-VSO-VDE-VOD-DEV', 'PRDCT-VSO-VDE-VOD-LAB', + 'PRDCT-VSO-VSW-AIS', 'PRDCT-VSO-VSW-CRESCENDO', 'PRDCT-VSO-VSW-ENTITLEMENTS', + 'PRDCT-VSO-VSW-GSD', 'PRDCT-VSO-VSW-IPVS', 'PRDCT-VSO-VSW-LANTERN', + 'PRDCT-VSO-VSW-LINEUPS', 'PRDCT-VSO-VSW-METADATA', 'PRDCT-VSO-VSW-NNS', + 'PRDCT-VSO-VSW-SETTINGS', 'PRDCT-VSO-VSW-SPECFLOW', 'PRDCT-VSO-VSW-SRE', + 'PRDCT-VSO-VSW-TEAM', 'PRDCT-VSO-VSW-TVE', 'PRDCT-VSO-VSW-VOD', 'PRDCT-VSO-VSW-VSI', + 'SB-EPS-ENTDATA', 'SDIT-CSD-ITLS-ACT', 'SDIT-CSD-ITLS-ENDS', 'SDIT-CSD-ITLS-LABOPS', + 'SDIT-CSD-ITLS-LNE', 'SDIT-CSD-ITLS-LPE', 'SDIT-CSD-ITLS-NSIE', 'SDIT-CSD-ITLS-PACE', + 'SDIT-CSD-ITLS-PIES', 'SDIT-CSD-ITLS-VLEO', 'SDIT-CSD-OBO-RDE', 'SDIT-DATA-ASSETS', + 'SDIT-DATAASSETS-MONGODBA', 'SDIT-DATAASSETS-ORACLEDBA', 'SDIT-EDIS-CDP-DAAS-DATALOGISTICS', + 'SDIT-EDIS-CDP-DAAS-NDS', 'SDIT-EDIS-CDS-DIGITALSERVICES', 'SDIT-EDIS-CIC-NEBULA', + 'SDIT-EDIS-ITEI-CLOUD', 'SDIT-EDIS-ITEI-EMAIL', 'SDIT-EDIS-ITEI-TAAS-CIE', + 'SDIT-EDIS-ITEI-TAAS-DM', 'SDIT-EDIS-NAAS-DESIGN', 'SDIT-EDIS-NAAS-FIREWALL', + 'SDIT-EDIS-NAAS-IMPLEMENTATION', 'SDIT-EDIS-NAAS-INSTALL', 'SDIT-EDIS-NAAS-NAT', + 'SDIT-EDIS-NAAS-OPERATIONS', 'SDIT-EDIS-PAAS-PRIVATE-CLOUD', 'SDIT-EDIS-PAAS-PUBCLD', + 'SDIT-ITSA-OPS', 'SDIT-ITSA-OPS-IDOS', 'SDIT-ITSA-OPS-PQR-SCI', 'SDIT-ITSA-TOO', + 'SDIT-MOBILE', 'SDIT-MOBILE-ACTIVATION', 'SDIT-PCDS-SA-ACTIVATIONS', + 'SDIT-PCDS-SA-ANALYSTS', 'SDIT-PCDS-SA-PROVISIONING', 'SDIT-PCDS-SA-SCI', + 'SDIT-SVCEXP-VCT-DESIGN', 'SN-OPS-NEWS', 'SPECTRUM ENTERPRISE', 'SPECTRUM REACH', + 'SROPS-DATA', 'SROPS-SPECTRUM REACH OPS', 'TEST-NEW-TEAM', 'TEST-OLD-TEAM', + 'VDE-MAPD-DEV', 'VDE-MAPD-DEV2', 'VDE-VOD-NVIS', 'VDE-VOD-REPORTING', + 'WTG-WAE-ACCESS ENGINEERING', 'WTG-WCE-SYS ENG', 'WTG-WDE-DEVICE ENGINEERING', + 'WTG-WRD-CONNECTIVITY', 'WTG-WRD-RESEARCH AND DEVELOPMENT', +]; + +export const EQUIP_STATUSES = ['ACTIVE', 'DESIGNED', 'PENDING DECOMMISSION', 'DECOMMISSIONED']; + +export const EQUIPMENT_CLASSES = ['S', 'C']; + +// Small list — columns that should render as searchable dropdowns +// Maps column ID → options array +export const COLUMN_PICKLISTS = { + RESPONSIBLE_TEAM: RESPONSIBLE_TEAMS, + EQUIP_STATUS: EQUIP_STATUSES, + EQUIPMENT_CLASS: EQUIPMENT_CLASSES, +};