From ae04bc981e97e51551c0932e0077e2156c45fc76 Mon Sep 17 00:00:00 2001 From: jramos Date: Mon, 16 Mar 2026 13:27:16 -0600 Subject: [PATCH] feat(reporting): add empty-cell option to column filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/pages/ReportingPage.js | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index b443ebc..fba13f4 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -7,6 +7,9 @@ import { useAuth } from '../../contexts/AuthContext'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; 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 // --------------------------------------------------------------------------- @@ -793,22 +796,28 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan // Unique values from the full (unfiltered) findings list. // 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 def = COLUMN_DEFS[colKey]; const vals = new Set(); + let hasEmpty = false; findings.forEach((f) => { 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 { 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]); const displayed = search.trim() - ? allValues.filter((v) => v.toLowerCase().includes(search.toLowerCase())) + ? allValues.filter((v) => v === EMPTY_SENTINEL || v.toLowerCase().includes(search.toLowerCase())) : allValues; const isChecked = (val) => !activeFilter || activeFilter.has(val); @@ -890,7 +899,10 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan onChange={() => toggle(val)} style={{ accentColor: '#0EA5E9', width: '12px', height: '12px', flexShrink: 0, cursor: 'pointer' }} /> - {val} + {val === EMPTY_SENTINEL + ? — empty — + : {val} + } ))} @@ -1188,9 +1200,12 @@ export default function ReportingPage({ filterDate, filterEXC }) { if (!vals || vals.size === 0) return false; const def = COLUMN_DEFS[key]; 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); }) ); }