feat(reporting): add empty-cell option to column filters
Columns that contain any blank values now show a '— empty —' entry at the
top of the filter dropdown. Selecting only that entry shows findings with
nothing in that column (e.g. workflow with no FP# ticket assigned).
Uses an EMPTY_SENTINEL constant ('__EMPTY__') in the filter Set so blank
cells are handled distinctly from non-blank values. Works for both
single-value and multi-value (CVEs) columns.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,9 @@ import { useAuth } from '../../contexts/AuthContext';
|
|||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
const STORAGE_KEY = 'steam_findings_columns_v2';
|
const STORAGE_KEY = 'steam_findings_columns_v2';
|
||||||
|
|
||||||
|
// Sentinel used in filter Sets to represent cells with no value (blank / —)
|
||||||
|
const EMPTY_SENTINEL = '__EMPTY__';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Column definitions — source of truth for labels, sort behaviour, rendering
|
// Column definitions — source of truth for labels, sort behaviour, rendering
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -793,22 +796,28 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
|
|||||||
|
|
||||||
// Unique values from the full (unfiltered) findings list.
|
// Unique values from the full (unfiltered) findings list.
|
||||||
// Multi-value columns (e.g. cves) expand their array so each item is a separate option.
|
// Multi-value columns (e.g. cves) expand their array so each item is a separate option.
|
||||||
|
// EMPTY_SENTINEL is prepended when any finding has a blank/null cell.
|
||||||
const allValues = useMemo(() => {
|
const allValues = useMemo(() => {
|
||||||
const def = COLUMN_DEFS[colKey];
|
const def = COLUMN_DEFS[colKey];
|
||||||
const vals = new Set();
|
const vals = new Set();
|
||||||
|
let hasEmpty = false;
|
||||||
findings.forEach((f) => {
|
findings.forEach((f) => {
|
||||||
if (def?.multiValue) {
|
if (def?.multiValue) {
|
||||||
(f[colKey] || []).forEach((v) => { if (String(v).trim()) vals.add(String(v).trim()); });
|
const arr = f[colKey] || [];
|
||||||
|
if (arr.length === 0) { hasEmpty = true; return; }
|
||||||
|
arr.forEach((v) => { if (String(v).trim()) vals.add(String(v).trim()); });
|
||||||
} else {
|
} else {
|
||||||
const v = getFilterVal(f, colKey).trim();
|
const v = getFilterVal(f, colKey).trim();
|
||||||
if (v) vals.add(v);
|
if (v) vals.add(v); else hasEmpty = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return [...vals].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
const sorted = [...vals].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||||
|
if (hasEmpty) sorted.unshift(EMPTY_SENTINEL);
|
||||||
|
return sorted;
|
||||||
}, [findings, colKey]);
|
}, [findings, colKey]);
|
||||||
|
|
||||||
const displayed = search.trim()
|
const displayed = search.trim()
|
||||||
? allValues.filter((v) => v.toLowerCase().includes(search.toLowerCase()))
|
? allValues.filter((v) => v === EMPTY_SENTINEL || v.toLowerCase().includes(search.toLowerCase()))
|
||||||
: allValues;
|
: allValues;
|
||||||
|
|
||||||
const isChecked = (val) => !activeFilter || activeFilter.has(val);
|
const isChecked = (val) => !activeFilter || activeFilter.has(val);
|
||||||
@@ -890,7 +899,10 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
|
|||||||
onChange={() => toggle(val)}
|
onChange={() => toggle(val)}
|
||||||
style={{ accentColor: '#0EA5E9', width: '12px', height: '12px', flexShrink: 0, cursor: 'pointer' }}
|
style={{ accentColor: '#0EA5E9', width: '12px', height: '12px', flexShrink: 0, cursor: 'pointer' }}
|
||||||
/>
|
/>
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
|
{val === EMPTY_SENTINEL
|
||||||
|
? <span style={{ fontStyle: 'italic', color: '#64748B', whiteSpace: 'nowrap' }}>— empty —</span>
|
||||||
|
: <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
|
||||||
|
}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1188,9 +1200,12 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
|||||||
if (!vals || vals.size === 0) return false;
|
if (!vals || vals.size === 0) return false;
|
||||||
const def = COLUMN_DEFS[key];
|
const def = COLUMN_DEFS[key];
|
||||||
if (def?.multiValue) {
|
if (def?.multiValue) {
|
||||||
return (f[key] || []).some((v) => vals.has(String(v).trim()));
|
const arr = f[key] || [];
|
||||||
|
if (arr.length === 0) return vals.has(EMPTY_SENTINEL);
|
||||||
|
return arr.some((v) => vals.has(String(v).trim()));
|
||||||
}
|
}
|
||||||
return vals.has(getFilterVal(f, key).trim());
|
const fval = getFilterVal(f, key).trim();
|
||||||
|
return fval === '' ? vals.has(EMPTY_SENTINEL) : vals.has(fval);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user