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:
Jordan Ramos
2026-06-10 09:47:25 -06:00
parent 1dbde36b53
commit 56ceb81ea5
3 changed files with 255 additions and 14 deletions

View File

@@ -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>
<input
style={INPUT}
value={bulkDefaults[col.id] || ''}
onChange={e => setBulkDefault(col.id, e.target.value)}
placeholder={`Default for all rows`}
/>
{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,14 +539,30 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
onClick={() => !isEditing && startEdit(rowIdx, col.id)}
>
{isEditing ? (
<input
style={{ ...INPUT, padding: '0.2rem 0.4rem', fontSize: '0.7rem' }}
value={editValue}
onChange={e => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={e => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingCell(null); }}
autoFocus
/>
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}
onChange={e => setEditValue(e.target.value)}
onBlur={commitEdit}
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>}

View 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>
);
}