Add searchable dropdowns for Granite Loader columns
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.
This commit is contained in:
@@ -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 }) {
|
||||
<label style={{ fontSize: '0.6rem', color: '#64748B', display: 'block', marginBottom: '0.15rem' }}>
|
||||
{col.id}
|
||||
</label>
|
||||
{COLUMN_PICKLISTS[col.id] ? (
|
||||
<SearchableSelect
|
||||
value={bulkDefaults[col.id] || ''}
|
||||
options={COLUMN_PICKLISTS[col.id]}
|
||||
onChange={(val) => setBulkDefault(col.id, val)}
|
||||
placeholder={`Default for all rows`}
|
||||
autoFocus={false}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
style={INPUT}
|
||||
value={bulkDefaults[col.id] || ''}
|
||||
onChange={e => setBulkDefault(col.id, e.target.value)}
|
||||
placeholder={`Default for all rows`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -527,6 +539,21 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
|
||||
onClick={() => !isEditing && startEdit(rowIdx, col.id)}
|
||||
>
|
||||
{isEditing ? (
|
||||
COLUMN_PICKLISTS[col.id] ? (
|
||||
<SearchableSelect
|
||||
value={editValue}
|
||||
options={COLUMN_PICKLISTS[col.id]}
|
||||
onChange={(val) => {
|
||||
setOverrides(prev => ({
|
||||
...prev,
|
||||
[rowIdx]: { ...(prev[rowIdx] || {}), [col.id]: val },
|
||||
}));
|
||||
setEditingCell(null);
|
||||
}}
|
||||
onClose={() => setEditingCell(null)}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
style={{ ...INPUT, padding: '0.2rem 0.4rem', fontSize: '0.7rem' }}
|
||||
value={editValue}
|
||||
@@ -535,6 +562,7 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingCell(null); }}
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.2rem' }}>
|
||||
{hasOverride && <span style={{ color: '#F59E0B', fontSize: '0.5rem' }}>●</span>}
|
||||
|
||||
148
frontend/src/components/SearchableSelect.js
Normal file
148
frontend/src/components/SearchableSelect.js
Normal file
@@ -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 (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
style={{
|
||||
background: '#0F172A', border: '1px solid #7C3AED', borderRadius: isOpen ? '0.375rem 0.375rem 0 0' : '0.375rem',
|
||||
color: '#E2E8F0', padding: '0.3rem 0.5rem', fontSize: '0.7rem', width: '100%',
|
||||
fontFamily: "'JetBrains Mono', monospace", outline: 'none', boxSizing: 'border-box',
|
||||
}}
|
||||
value={filter}
|
||||
onChange={e => { 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 && (
|
||||
<div ref={listRef} style={DROPDOWN_STYLE}>
|
||||
{filteredOptions.map((opt, i) => (
|
||||
<div
|
||||
key={opt}
|
||||
style={i === highlightIdx ? OPTION_HIGHLIGHT : OPTION_STYLE}
|
||||
onMouseEnter={() => setHighlightIdx(i)}
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSelect(opt); }}
|
||||
>
|
||||
{opt}
|
||||
</div>
|
||||
))}
|
||||
{filteredOptions.length === 50 && (
|
||||
<div style={{ ...OPTION_STYLE, color: '#64748B', fontStyle: 'italic' }}>
|
||||
Type to filter more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
frontend/src/utils/graniteLoaderPicklists.js
Normal file
65
frontend/src/utils/graniteLoaderPicklists.js
Normal file
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user