Add drag-fill and bulk default cascade to Granite Loader

- Drag-fill: hover bottom-right corner of a cell to get a fill handle,
  drag down to fill that value into rows below (Excel-style)
- Bulk default deletion now cascades: clearing a default also removes
  per-row overrides that matched that value
- initialDevices now passes equip_inst_id through from supplemental data
This commit is contained in:
Jordan Ramos
2026-08-04 15:30:58 -06:00
parent 4852ea3c1a
commit c7a4e411ce

View File

@@ -18,7 +18,8 @@ import { generateLoaderXlsx, generateFilename } from '../utils/graniteLoaderExpo
import { COLUMN_PICKLISTS } from '../utils/graniteLoaderPicklists';
import SearchableSelect from './SearchableSelect';
// ⚠️ CONVENTION: Fallback should be '/api' (relative), not an absolute URL with host:port
// ⚠️ CONVENTION: Fallback should be '/api' (relative), not an absolute URL with host:port.
// The env var resolves correctly in prod/staging, but the fallback violates the "no absolute URLs" convention.
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// ---------------------------------------------------------------------------
@@ -63,6 +64,7 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
const [overrides, setOverrides] = useState({});
const [editingCell, setEditingCell] = useState(null);
const [editValue, setEditValue] = useState('');
const [fillDrag, setFillDrag] = useState(null); // { sourceRow, colId, value, currentRow }
const [enriching, setEnriching] = useState(false);
const [enrichErrors, setEnrichErrors] = useState([]);
const [cardConfigured, setCardConfigured] = useState(false);
@@ -115,6 +117,7 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
IPV4_ADDRESS: d.ip_address || '',
EQUIP_NAME: d.hostname || '',
_host_id: d.host_id || null,
EQUIP_INST_ID: d.equip_inst_id || '',
})));
} else {
setDevices([]);
@@ -124,6 +127,7 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
setEnrichErrors([]);
setValidationWarnings([]);
setEnriching(false);
setFillDrag(null);
}, [isOpen, initialDevices]);
// Auto-select required columns + useful defaults when operation type changes
@@ -140,6 +144,14 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
});
}, [operationType]);
// Global mouseup to commit fill drag if released anywhere
useEffect(() => {
if (!fillDrag) return;
const handleMouseUp = () => commitFillDrag();
window.addEventListener('mouseup', handleMouseUp);
return () => window.removeEventListener('mouseup', handleMouseUp);
}); // eslint-disable-line react-hooks/exhaustive-deps
// --- Column selection ---
const toggleColumn = useCallback((colId) => {
const required = getRequiredColumns(operationType);
@@ -220,13 +232,61 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
});
};
// --- Drag-fill: Excel-style fill-down ---
const startFillDrag = (rowIdx, colId) => {
const value = getCellValue(rowIdx, colId);
if (!value) return;
setFillDrag({ sourceRow: rowIdx, colId, value, currentRow: rowIdx });
};
const onFillDragEnterRow = (rowIdx) => {
if (!fillDrag) return;
if (rowIdx > fillDrag.sourceRow) {
setFillDrag(prev => ({ ...prev, currentRow: rowIdx }));
}
};
const commitFillDrag = () => {
if (!fillDrag || fillDrag.currentRow <= fillDrag.sourceRow) {
setFillDrag(null);
return;
}
setOverrides(prev => {
const next = { ...prev };
for (let i = fillDrag.sourceRow + 1; i <= fillDrag.currentRow; i++) {
next[i] = { ...(next[i] || {}), [fillDrag.colId]: fillDrag.value };
}
return next;
});
setFillDrag(null);
};
// --- Bulk default ---
const setBulkDefault = (colId, value) => {
// For APP_ID_ASSET_TAG, extract just "APP_ID - APPREFID" from the full display string
const resolved = (colId === 'APP_ID_ASSET_TAG' && value.includes(' | '))
? value.replace(/^★\s*/, '').split(' | ')[1].replace(/\s*\[.*\]$/, '').trim()
: value;
const oldValue = bulkDefaults[colId] || '';
setBulkDefaults(prev => ({ ...prev, [colId]: resolved }));
// When clearing a bulk default, also clear per-row overrides that matched
// the old default value — user intent is "remove this from everything"
if (!resolved && oldValue) {
setOverrides(prev => {
const next = { ...prev };
for (const [rowKey, rowOverrides] of Object.entries(next)) {
if (rowOverrides[colId] === oldValue) {
const updated = { ...rowOverrides };
delete updated[colId];
if (Object.keys(updated).length === 0) delete next[rowKey];
else next[rowKey] = updated;
}
}
return next;
});
}
};
// --- Paste IPs (standalone mode) ---
@@ -589,6 +649,8 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
const isEditing = editingCell?.rowIdx === rowIdx && editingCell?.colId === col.id;
const hasOverride = isOverridden(rowIdx, col.id);
const hasWarning = isCellWarning(rowIdx, col.id);
const isFillTarget = fillDrag && fillDrag.colId === col.id && rowIdx > fillDrag.sourceRow && rowIdx <= fillDrag.currentRow;
const isFillSource = fillDrag && fillDrag.colId === col.id && rowIdx === fillDrag.sourceRow;
return (
<td
@@ -596,11 +658,14 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
style={{
padding: '0.2rem 0.4rem',
position: 'relative',
background: hasWarning ? 'rgba(239, 68, 68, 0.08)' : 'transparent',
background: isFillTarget ? 'rgba(20,184,166,0.12)' : hasWarning ? 'rgba(239, 68, 68, 0.08)' : 'transparent',
cursor: 'pointer',
minWidth: '100px',
borderLeft: isFillTarget ? '2px solid rgba(20,184,166,0.4)' : undefined,
}}
onClick={() => !isEditing && startEdit(rowIdx, col.id)}
onClick={() => !isEditing && !fillDrag && startEdit(rowIdx, col.id)}
onMouseEnter={() => onFillDragEnterRow(rowIdx)}
onMouseUp={() => fillDrag && commitFillDrag()}
>
{isEditing ? (
getPicklist(col.id) ? (
@@ -642,6 +707,27 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
title="Revert to bulk default"
></button>
)}
{value && !fillDrag && (
<span
onMouseDown={e => { e.stopPropagation(); e.preventDefault(); startFillDrag(rowIdx, col.id); }}
style={{
position: 'absolute', bottom: '1px', right: '1px',
width: '7px', height: '7px', background: '#14B8A6',
border: '1px solid #0F172A', cursor: 'crosshair',
opacity: 0, transition: 'opacity 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
onMouseLeave={e => { if (!fillDrag) e.currentTarget.style.opacity = '0'; }}
title="Drag to fill down"
/>
)}
{isFillSource && fillDrag && (
<span style={{
position: 'absolute', bottom: '1px', right: '1px',
width: '7px', height: '7px', background: '#14B8A6',
border: '1px solid #0F172A',
}} />
)}
</div>
)}
</td>