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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user