feat: add row visibility controls to Reporting page — hide/bulk-hide rows, localStorage persistence, visibility manager popover, chart/export integration

This commit is contained in:
jramos
2026-04-15 13:15:01 -06:00
parent 938dda400a
commit ed48522932
5 changed files with 1019 additions and 6 deletions

View File

@@ -0,0 +1 @@
{"specId": "acff93bd-0045-4fcd-b948-cc52c7cc5ec6", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,400 @@
# Design Document: Reporting Row Visibility
## Overview
This feature adds row-level visibility controls to the Reporting page's findings table, allowing security analysts to temporarily hide specific rows from view. Hidden rows are excluded from the visible table, the Action Coverage donut chart, and CSV/XLSX exports. The feature is entirely frontend — no backend changes are needed. All state is persisted in browser localStorage.
The feature consists of five interconnected pieces:
1. **Per-row hide button** — An `EyeOff` icon button on each table row that hides that finding
2. **Bulk select and hide** — Checkboxes on each row, a select-all checkbox, and a bulk action toolbar for hiding multiple rows at once
3. **Row Visibility Manager** — A toolbar popover (modeled after the existing `ColumnManager`) for viewing and restoring hidden rows
4. **Chart integration** — The Action Coverage donut chart recalculates using only visible (non-hidden) findings
5. **Export integration** — CSV and XLSX exports include only visible rows
### Design Decisions
- **localStorage over backend storage**: Row visibility is a personal view preference, not shared team state. Storing it in localStorage keeps the feature zero-cost on the backend and avoids auth/permission complexity. This mirrors how column visibility is already handled via `STORAGE_KEY`.
- **Hide-before-filter pipeline**: Hidden rows are removed from the dataset before column filters, action coverage filters, and EXC filters are applied. This ensures hidden rows never appear in filter dropdowns or affect filter counts.
- **Stale IDs are retained silently**: If a hidden Finding_ID no longer exists after an Ivanti sync, the ID stays in localStorage. This avoids the need for cleanup logic and ensures the finding is re-hidden if it reappears in a future sync.
- **Selection state is transient**: The checkbox selection state (`Row_Selection_State`) is not persisted. It resets on page reload and clears after a bulk hide operation.
## Architecture
The feature is contained entirely within `frontend/src/components/pages/ReportingPage.js`. No new files are created. The changes integrate into the existing component hierarchy and data flow.
```mermaid
flowchart TD
subgraph ReportingPage State
A[findings - raw from API] --> B[hiddenRowIds - from localStorage]
B --> C[visibleFindings = findings minus hiddenRowIds]
C --> D[filtered - column/action/EXC filters applied]
D --> E[sorted - sort order applied]
E --> F[Table rows rendered]
C --> G[ActionCoverageDonut receives visibleFindings]
E --> H[Export functions use sorted visible rows]
end
subgraph New Components
I[RowVisibilityManager popover]
J[BulkActionToolbar]
K[Selection checkboxes]
L[Per-row hide button]
end
I -->|restore| B
J -->|bulk hide| B
L -->|single hide| B
K -->|toggle selection| M[selectedRowIds - transient state]
M --> J
```
### Data Flow Pipeline
The existing filtering pipeline in `VulnerabilityTriagePage` is:
```
findings → columnFilters → actionFilter → excFilter → sorted → rendered/exported
```
The new pipeline inserts row hiding as the first step:
```
findings → HIDE (remove hiddenRowIds) → columnFilters → actionFilter → excFilter → sorted → rendered/exported
```
This ensures hidden rows are excluded before any other filtering logic runs.
## Components and Interfaces
### 1. Hidden Row State Management
New state and helpers added to `VulnerabilityTriagePage`:
```javascript
const HIDDEN_ROWS_KEY = 'steam_findings_hidden_rows';
// Load hidden row IDs from localStorage
function loadHiddenRows() {
try {
const saved = JSON.parse(localStorage.getItem(HIDDEN_ROWS_KEY) || 'null');
if (saved && Array.isArray(saved)) return new Set(saved);
} catch { /* corrupted — treat as empty */ }
return new Set();
}
// Persist hidden row IDs to localStorage
function saveHiddenRows(hiddenSet) {
try {
localStorage.setItem(HIDDEN_ROWS_KEY, JSON.stringify([...hiddenSet]));
} catch { /* localStorage unavailable — degrade silently */ }
}
```
**State declarations:**
```javascript
const [hiddenRowIds, setHiddenRowIds] = useState(loadHiddenRows);
const [selectedRowIds, setSelectedRowIds] = useState(new Set());
```
**Hide/restore operations:**
```javascript
// Hide a single row
const hideRow = useCallback((findingId) => {
setHiddenRowIds(prev => {
const next = new Set(prev);
next.add(String(findingId));
saveHiddenRows(next);
return next;
});
}, []);
// Restore a single row
const restoreRow = useCallback((findingId) => {
setHiddenRowIds(prev => {
const next = new Set(prev);
next.delete(String(findingId));
saveHiddenRows(next);
return next;
});
}, []);
// Restore all hidden rows
const restoreAllRows = useCallback(() => {
setHiddenRowIds(new Set());
saveHiddenRows(new Set());
}, []);
// Bulk hide selected rows
const hideSelectedRows = useCallback(() => {
setHiddenRowIds(prev => {
const next = new Set(prev);
selectedRowIds.forEach(id => next.add(String(id)));
saveHiddenRows(next);
return next;
});
setSelectedRowIds(new Set());
}, [selectedRowIds]);
```
### 2. Modified Filtering Pipeline
The existing `filtered` useMemo is modified to exclude hidden rows first:
```javascript
// New: visible findings (hidden rows removed) — fed to ActionCoverageDonut
const visibleFindings = useMemo(() => {
if (hiddenRowIds.size === 0) return findings;
return findings.filter(f => !hiddenRowIds.has(String(f.id)));
}, [findings, hiddenRowIds]);
// Modified: filtered now starts from visibleFindings instead of findings
const filtered = useMemo(() => {
let result = visibleFindings;
// ... existing column filter, action filter, EXC filter logic unchanged
}, [visibleFindings, columnFilters, actionFilter, excFilter]);
```
The `ActionCoverageDonut` receives `visibleFindings` instead of `findings`:
```jsx
<ActionCoverageDonut
findings={visibleFindings} // was: findings
activeSegment={actionFilter}
onSegmentClick={...}
/>
```
### 3. Selection State Management
```javascript
// Clear selection when visible rows change (filter changes)
useEffect(() => {
setSelectedRowIds(prev => {
const visibleIds = new Set(sorted.map(f => String(f.id)));
const next = new Set([...prev].filter(id => visibleIds.has(id)));
return next.size === prev.size ? prev : next;
});
}, [sorted]);
// Toggle a single row's selection
const toggleRowSelection = useCallback((findingId) => {
setSelectedRowIds(prev => {
const next = new Set(prev);
const id = String(findingId);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
// Select/deselect all visible rows
const toggleSelectAll = useCallback(() => {
const allVisibleIds = sorted.map(f => String(f.id));
setSelectedRowIds(prev => {
if (prev.size === allVisibleIds.length) return new Set(); // deselect all
return new Set(allVisibleIds); // select all
});
}, [sorted]);
```
### 4. RowVisibilityManager Component
A new component following the same pattern as `ColumnManager`:
```javascript
function RowVisibilityManager({ hiddenRowIds, findings, onRestore, onRestoreAll }) {
// Props:
// hiddenRowIds: Set<string> — currently hidden finding IDs
// findings: Array — full findings array (to look up titles for hidden IDs)
// onRestore: (findingId: string) => void
// onRestoreAll: () => void
//
// State:
// open: boolean — popover visibility
//
// Behavior:
// - Button shows EyeOff icon + "Hidden (N)" count
// - Popover lists hidden findings by ID and title
// - Each entry has an Eye icon restore button
// - "Restore All" button at the bottom
// - When hiddenRowIds.size === 0, popover shows "No rows hidden" message
// - Closes on outside click (same pattern as ColumnManager)
}
```
**Placement:** Rendered in the toolbar `div` adjacent to the `ColumnManager` button, between the ColumnManager and the Sync button.
### 5. BulkActionToolbar Component
Rendered above the table when `selectedRowIds.size > 0`:
```javascript
function BulkHideToolbar({ count, onHide, onClear }) {
// Props:
// count: number — selected row count
// onHide: () => void — hide all selected rows
// onClear: () => void — clear selection
//
// Renders:
// "{count} rows selected" label
// "Hide Selected" button with EyeOff icon
// "Clear" button to deselect all
}
```
**Placement:** Rendered inside the table scroll container, above the `<table>` element, in the same position as the existing `SelectionToolbar` for batch FP submissions. The bulk hide toolbar appears alongside (or replaces) the existing selection toolbar depending on context.
### 6. Per-Row Hide Button and Selection Checkbox
Two new fixed columns are added to the table, before the existing checkbox column:
| Column | Width | Content | Position |
|--------|-------|---------|----------|
| Selection checkbox | 36px | `Square` / `CheckSquare` icon (lucide-react) | First column |
| Hide button | 36px | `EyeOff` icon button | Second column |
Both columns are fixed (not managed by `ColumnManager`) and use sticky positioning in the header.
### 7. Select All Checkbox
Rendered in the table header for the selection column:
- **Unchecked** (`Square` icon): No rows selected
- **Checked** (`CheckSquare` icon): All visible rows selected
- **Indeterminate** (`MinusSquare` icon): Some but not all visible rows selected
## Data Models
### localStorage Schema
**Key:** `steam_findings_hidden_rows`
**Value:** JSON array of Finding ID strings
```json
["12345", "67890", "11111"]
```
**Constraints:**
- Finding IDs are stored as strings for consistent comparison
- The array may contain IDs that no longer exist in the current findings dataset (stale IDs are retained)
- An empty array `[]` or missing key both mean "no rows hidden"
- If the stored value fails JSON parsing, it is treated as empty (all rows visible)
### Component State
| State Variable | Type | Persisted | Description |
|---------------|------|-----------|-------------|
| `hiddenRowIds` | `Set<string>` | Yes (localStorage) | Finding IDs currently hidden |
| `selectedRowIds` | `Set<string>` | No (transient) | Finding IDs currently selected via checkboxes |
### Derived Data
| Variable | Derivation | Used By |
|----------|-----------|---------|
| `visibleFindings` | `findings.filter(f => !hiddenRowIds.has(f.id))` | ActionCoverageDonut, filter pipeline |
| `filtered` | `visibleFindings` → column filters → action filter → EXC filter | Sort, table render |
| `sorted` | `filtered` → sort comparator | Table render, export |
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Hidden row filtering invariant
*For any* array of findings and *any* set of hidden Finding_IDs, the `visibleFindings` array SHALL never contain a finding whose ID is in the hidden set.
**Validates: Requirements 1.1, 1.2, 4.1, 4.3, 5.1, 5.2, 6.1, 6.2**
### Property 2: localStorage round-trip preserves hidden row state
*For any* set of valid Finding_ID strings, calling `saveHiddenRows(set)` followed by `loadHiddenRows()` SHALL return a set containing exactly the same elements.
**Validates: Requirements 2.1, 2.2**
### Property 3: Corrupted localStorage produces empty set
*For any* string that is not a valid JSON array of strings, `loadHiddenRows()` SHALL return an empty set, and no error SHALL be thrown.
**Validates: Requirements 2.4**
### Property 4: Restore removes exactly the specified ID
*For any* non-empty set of hidden Finding_IDs and *any* ID in that set, calling `restoreRow(id)` SHALL produce a new hidden set that is equal to the original set minus that single ID.
**Validates: Requirements 3.3**
### Property 5: Bulk hide produces the union of hidden and selected sets
*For any* set of currently hidden Finding_IDs and *any* set of selected Finding_IDs, calling `hideSelectedRows()` SHALL produce a hidden set equal to the union of both sets, and the selection set SHALL be empty afterward.
**Validates: Requirements 8.5, 8.6**
### Property 6: Selection is always a subset of visible rows
*For any* set of selected Finding_IDs and *any* change to the visible row set (via filter changes or row hiding), the resulting selection set SHALL be a subset of the current visible row IDs.
**Validates: Requirements 8.8**
### Property 7: Select all produces exactly the visible row ID set
*For any* array of currently visible (sorted) findings, calling `toggleSelectAll` when no rows are selected SHALL produce a selection set equal to the set of all visible Finding_IDs. Calling `toggleSelectAll` when all rows are selected SHALL produce an empty selection set.
**Validates: Requirements 8.2, 8.3**
## Error Handling
| Scenario | Behavior |
|----------|----------|
| localStorage unavailable (private browsing, quota exceeded) | `saveHiddenRows` fails silently via try/catch. `loadHiddenRows` returns empty set. All rows remain visible. Feature degrades to session-only (hidden state lost on reload). |
| Corrupted localStorage value | `loadHiddenRows` catches JSON parse error and returns empty set. No error shown to user. |
| Stale Finding_ID in hidden set (ID no longer in findings after sync) | ID is retained in localStorage. The `filter()` call simply doesn't match any finding, so no visible effect. If the finding reappears in a future sync, it will be hidden again automatically. |
| Empty findings array | `visibleFindings` is empty. `selectedRowIds` is empty. Charts show "No data" state. Export produces headers only. All controls render but have no actionable items. |
| Bulk hide with empty selection | The "Hide Selected" button is only shown when `selectedRowIds.size > 0`, so this state is unreachable via the UI. If called programmatically, `hideSelectedRows` is a no-op (union with empty set). |
| Select all with no visible rows | `toggleSelectAll` produces an empty set (no rows to select). |
## Testing Strategy
### Property-Based Tests
The feature's core logic — set operations, filtering, localStorage serialization — is well-suited for property-based testing. The functions under test are pure or near-pure (localStorage can be mocked), and the input space (sets of string IDs, arrays of finding objects) is large.
**Library:** [fast-check](https://github.com/dubzzz/fast-check) — the standard PBT library for JavaScript/React projects.
**Configuration:** Each property test runs a minimum of 100 iterations.
**Tag format:** Each test is tagged with a comment: `// Feature: reporting-row-visibility, Property N: <property text>`
**Properties to implement:**
| Property | Test Description | Key Generators |
|----------|-----------------|----------------|
| 1 | Filter findings by hidden set, verify no hidden ID in output | `fc.array(findingArb)`, `fc.uniqueArray(fc.string())` |
| 2 | Save then load hidden rows, verify round-trip equality | `fc.uniqueArray(fc.stringOf(fc.constantFrom(...digits), {minLength: 1}))` |
| 3 | Set corrupted string in localStorage, verify loadHiddenRows returns empty set | `fc.string()` filtered to exclude valid JSON arrays |
| 4 | Remove one ID from hidden set, verify set difference | `fc.uniqueArray(fc.string(), {minLength: 1})`, pick random element |
| 5 | Union hidden + selected sets, verify result and empty selection | `fc.uniqueArray(fc.string())` × 2 |
| 6 | Generate selection + filter change, verify selection ⊆ visible | `fc.uniqueArray(fc.string())` for selection and visible sets |
| 7 | Select all from visible set, verify equality; toggle again, verify empty | `fc.array(findingArb)` |
### Unit Tests (Example-Based)
Unit tests cover specific scenarios, UI rendering, and edge cases that don't benefit from randomized input:
- **Hide button renders on each row** (Req 1.3) — verify EyeOff icon in fixed column
- **Hide button visible for viewer role** (Req 1.4) — render with read-only auth context
- **localStorage write on hide/restore** (Req 2.3) — mock localStorage, verify setItem called
- **Row Visibility Manager button shows count** (Req 3.1) — verify "Hidden (N)" text
- **Row Visibility Manager popover lists hidden findings** (Req 3.2) — click button, verify list
- **Restore All clears all hidden rows** (Req 3.4) — verify empty set after restoreAll
- **"Hidden (0)" when no rows hidden** (Req 3.5) — verify button text and empty message
- **Chart re-renders after hide** (Req 4.2) — verify ActionCoverageDonut receives updated findings
- **Hidden state preserved across sync** (Req 5.3) — simulate sync, verify hiddenRowIds unchanged
- **Stale IDs retained silently** (Req 5.4) — hide ID, remove from findings, verify no error
- **Bulk action toolbar appears with count** (Req 8.4) — select rows, verify toolbar renders
- **Indeterminate checkbox state** (Req 8.9) — partial selection, verify MinusSquare icon
- **Toolbar hidden when no selection** (Req 8.10) — empty selection, verify no toolbar
- **Styling consistency** (Req 7.1, 7.2, 7.3, 8.11) — snapshot tests for visual consistency

View File

@@ -0,0 +1,115 @@
# Requirements Document
## Introduction
The Reporting page in the STEAM Security Dashboard displays a table of Ivanti host findings with columns for finding ID, severity, title, CVEs, hostname, IP address, DNS, due date, SLA status, BU ownership, workflow, last found date, and notes. Some findings have manually entered notes such as "NOT STEAM/ACCESS", "MongoDB Update", or other free-text annotations indicating that work is being done outside of the automated FP or Archer exception workflows. These manually-noted findings are classified as "pending" in the Action Coverage donut chart, inflating the pending count even though they represent active remediation efforts.
Users need the ability to temporarily hide specific rows from the table view — similar to how columns can already be hidden via the ColumnManager popover. Hidden rows should be excluded from the visible table and from the Action Coverage chart calculations, but the underlying data must remain intact. The feature should persist across page reloads and provide a clear mechanism to reveal hidden rows or restore them individually.
## Glossary
- **Reporting_Table**: The findings data table rendered on the Reporting page, displaying one row per Ivanti host finding with sortable, filterable columns.
- **Row_Visibility_State**: A client-side record of which finding IDs have been hidden by the user. Stored in browser localStorage for persistence across sessions.
- **Hidden_Row**: A finding whose ID is present in the Row_Visibility_State hidden set. Hidden rows are excluded from the visible table and from chart metric calculations.
- **ColumnManager**: The existing popover component on the Reporting page that allows users to show/hide columns and reorder them via drag-and-drop. The row-hiding feature follows a similar UX pattern.
- **Action_Coverage_Chart**: The donut chart on the Reporting page that classifies open findings into three categories — FP Request, Archer Exception, and Pending — based on workflow status and note content.
- **Row_Visibility_Manager**: A new UI component that provides controls for viewing and restoring hidden rows, analogous to the ColumnManager for columns.
- **Finding_ID**: The unique Ivanti-assigned identifier for each host finding, used as the key for tracking hidden rows.
- **Row_Selection_State**: A transient client-side record of which Finding_IDs are currently selected via checkboxes. This state is not persisted and resets on page reload or after a bulk action completes.
- **Selection_Checkbox**: A checkbox control rendered in a fixed column on each visible row, used to toggle that row's inclusion in the Row_Selection_State.
- **Select_All_Checkbox**: A checkbox control rendered in the table header that toggles selection of all currently visible (non-hidden, post-filter) rows.
- **Bulk_Action_Toolbar**: A contextual toolbar that appears above the Reporting_Table when one or more rows are selected, displaying the count of selected rows and bulk action controls.
## Requirements
### Requirement 1: Hide Individual Rows from the Reporting Table
**User Story:** As a security analyst, I want to hide specific rows in the Reporting table by clicking a hide control on each row, so that I can remove manually-handled findings from view without deleting them.
#### Acceptance Criteria
1. THE Reporting_Table SHALL display a hide button on each row that, when clicked, adds the row's Finding_ID to the Row_Visibility_State hidden set.
2. WHEN a row's Finding_ID is added to the Row_Visibility_State hidden set, THE Reporting_Table SHALL immediately remove that row from the visible table without a page reload.
3. THE hide button SHALL be rendered as an icon button (using the `EyeOff` icon from lucide-react) in a fixed column that is not managed by the ColumnManager.
4. WHEN the user has no write permissions (viewer role), THE Reporting_Table SHALL still display the hide button, as row visibility is a personal view preference and not a data modification.
### Requirement 2: Persist Hidden Row State Across Sessions
**User Story:** As a security analyst, I want my hidden row selections to persist when I navigate away and return to the Reporting page, so that I do not have to re-hide the same rows every session.
#### Acceptance Criteria
1. THE Row_Visibility_State SHALL be stored in browser localStorage under a dedicated key (e.g., `steam_findings_hidden_rows`).
2. WHEN the Reporting page loads, THE Reporting_Table SHALL read the Row_Visibility_State from localStorage and exclude hidden Finding_IDs from the visible table.
3. WHEN the Row_Visibility_State changes (row hidden or restored), THE Reporting_Table SHALL write the updated state to localStorage immediately.
4. IF localStorage is unavailable or the stored value is corrupted, THEN THE Reporting_Table SHALL treat all rows as visible and continue operating without error.
### Requirement 3: Row Visibility Manager Panel
**User Story:** As a security analyst, I want a panel that shows me which rows are currently hidden and lets me restore them, so that I can manage my hidden rows and bring back findings I no longer want to hide.
#### Acceptance Criteria
1. THE Row_Visibility_Manager SHALL be accessible via a toolbar button placed adjacent to the existing ColumnManager button, using the `EyeOff` icon and displaying a count of currently hidden rows (e.g., "Hidden (3)").
2. WHEN the Row_Visibility_Manager button is clicked, THE Row_Visibility_Manager SHALL open a popover panel listing all currently hidden findings by Finding_ID and title.
3. THE Row_Visibility_Manager panel SHALL provide a restore button (using the `Eye` icon) next to each hidden finding entry that, when clicked, removes that Finding_ID from the Row_Visibility_State and returns the row to the visible table.
4. THE Row_Visibility_Manager panel SHALL provide a "Restore All" button that clears the entire Row_Visibility_State and returns all hidden rows to the visible table.
5. WHEN no rows are hidden, THE Row_Visibility_Manager button SHALL display "Hidden (0)" and the popover panel SHALL display a message indicating no rows are hidden.
### Requirement 4: Exclude Hidden Rows from Action Coverage Metrics
**User Story:** As a security analyst, I want hidden rows to be excluded from the Action Coverage donut chart, so that manually-handled findings I have hidden do not inflate the "Pending" count.
#### Acceptance Criteria
1. THE Action_Coverage_Chart SHALL compute its FP Request, Archer Exception, and Pending counts using only visible (non-hidden) findings.
2. WHEN a row is hidden or restored, THE Action_Coverage_Chart SHALL recalculate and re-render immediately to reflect the updated visible finding set.
3. THE Action_Coverage_Chart segment click filtering SHALL operate only on visible findings, so clicking a segment filters within the non-hidden set.
### Requirement 5: Hidden Row Interaction with Existing Filters
**User Story:** As a security analyst, I want row hiding to work correctly alongside column filters, sort order, and the action coverage chart filter, so that hiding rows does not interfere with other table controls.
#### Acceptance Criteria
1. THE Reporting_Table SHALL apply row hiding before column filters, so that hidden rows are excluded from the dataset before any column filter, sort, or action coverage filter is applied.
2. WHEN a finding is hidden and a column filter is active, THE Reporting_Table SHALL not include the hidden finding in filter value dropdowns or filter counts.
3. WHEN findings are synced from Ivanti (Sync button), THE Row_Visibility_State SHALL be preserved — previously hidden Finding_IDs remain hidden if they still exist in the refreshed dataset.
4. IF a hidden Finding_ID no longer exists in the synced findings data, THEN THE Row_Visibility_State SHALL retain the ID silently (no error) so that it is automatically re-hidden if the finding reappears in a future sync.
### Requirement 6: Export Behavior for Hidden Rows
**User Story:** As a security analyst, I want CSV and XLSX exports to include only visible rows by default, so that my exports reflect the same filtered view I see on screen.
#### Acceptance Criteria
1. WHEN the user exports data via CSV or XLSX, THE Reporting_Table SHALL export only the currently visible (non-hidden, post-filter) rows.
2. THE export SHALL respect all active filters (column filters, action coverage filter, EXC filter) in addition to row hiding, exporting only the intersection of all active view constraints.
### Requirement 7: Visual Styling Consistency
**User Story:** As a security analyst, I want the row-hiding controls to match the existing dashboard aesthetic, so that the feature feels native to the application.
#### Acceptance Criteria
1. THE hide button on each row SHALL use the same icon size (13px), color palette (muted slate for default, accent blue on hover), and monospace font styling as existing toolbar controls.
2. THE Row_Visibility_Manager popover SHALL use the same panel styling (dark gradient background, accent border, box shadow) as the existing ColumnManager popover.
3. THE Row_Visibility_Manager toolbar button SHALL use the same button styling (padding, border radius, font size, uppercase text) as the existing ColumnManager and Queue toolbar buttons.
### Requirement 8: Bulk Hide Rows via Multi-Select
**User Story:** As a security analyst, I want to select multiple rows and hide them all at once, so that I can quickly clear out batches of manually-handled findings without clicking hide on each row individually.
#### Acceptance Criteria
1. THE Reporting_Table SHALL display a Selection_Checkbox on each visible row in a fixed column that is not managed by the ColumnManager, positioned before the hide button column.
2. THE Reporting_Table SHALL display a Select_All_Checkbox in the table header of the selection column that, when checked, adds all currently visible (non-hidden, post-filter) Finding_IDs to the Row_Selection_State.
3. WHEN the Select_All_Checkbox is unchecked, THE Reporting_Table SHALL remove all Finding_IDs from the Row_Selection_State.
4. WHEN one or more Finding_IDs are present in the Row_Selection_State, THE Bulk_Action_Toolbar SHALL appear above the Reporting_Table displaying the count of selected rows (e.g., "3 rows selected") and a "Hide Selected" button using the `EyeOff` icon.
5. WHEN the "Hide Selected" button is clicked, THE Reporting_Table SHALL add all Finding_IDs in the Row_Selection_State to the Row_Visibility_State hidden set in a single operation.
6. WHEN a bulk hide operation completes, THE Reporting_Table SHALL clear the Row_Selection_State so that no rows remain selected.
7. WHEN a bulk hide operation completes, THE Action_Coverage_Chart SHALL recalculate and re-render immediately to reflect the updated visible finding set.
8. WHEN column filters or the action coverage filter change the set of visible rows, THE Row_Selection_State SHALL remove any Finding_IDs that are no longer visible, so that the selection always reflects the current filtered view.
9. THE Select_All_Checkbox SHALL display an indeterminate state when some but not all visible rows are selected.
10. WHEN no rows are selected, THE Bulk_Action_Toolbar SHALL not be displayed.
11. THE Selection_Checkbox, Select_All_Checkbox, and Bulk_Action_Toolbar SHALL use the same color palette (muted slate for default, accent blue for checked/active state), monospace font styling, and dark gradient background as existing toolbar controls defined in the design system.

View File

@@ -0,0 +1,127 @@
# Implementation Plan: Reporting Row Visibility
## Overview
This plan implements row-level visibility controls for the Reporting page's findings table. All changes are contained within `frontend/src/components/pages/ReportingPage.js` — no new files, no backend changes. The implementation adds hidden row state management (localStorage-persisted), a visibility filtering step in the data pipeline, selection checkboxes with bulk hide, a Row Visibility Manager popover, chart/export integration, and per-row hide buttons. Each task builds incrementally on the previous one, wiring everything together by the final step.
## Tasks
- [x] 1. Add hidden row state management and localStorage helpers
- Add the `HIDDEN_ROWS_KEY` constant (`'steam_findings_hidden_rows'`)
- Implement `loadHiddenRows()` function that reads from localStorage, parses JSON, returns a `Set<string>` (empty set on parse failure or missing key)
- Implement `saveHiddenRows(hiddenSet)` function that serializes the set to a JSON array and writes to localStorage (silent catch on failure)
- Add `hiddenRowIds` state initialized via `useState(loadHiddenRows)`
- Implement `hideRow(findingId)` callback that adds a string ID to the set and persists
- Implement `restoreRow(findingId)` callback that removes a string ID from the set and persists
- Implement `restoreAllRows()` callback that clears the set and persists an empty set
- _Requirements: 1.1, 2.1, 2.2, 2.3, 2.4_
- [ ]* 1.1 Write property test: localStorage round-trip preserves hidden row state
- **Property 2: localStorage round-trip preserves hidden row state**
- Generate arbitrary sets of valid Finding_ID strings, call `saveHiddenRows` then `loadHiddenRows`, assert the returned set contains exactly the same elements
- **Validates: Requirements 2.1, 2.2**
- [ ]* 1.2 Write property test: corrupted localStorage produces empty set
- **Property 3: Corrupted localStorage produces empty set**
- Generate arbitrary strings that are not valid JSON arrays of strings, set them in localStorage under the hidden rows key, call `loadHiddenRows`, assert the result is an empty set and no error is thrown
- **Validates: Requirements 2.4**
- [x] 2. Insert visibility filtering into the data pipeline
- Add `visibleFindings` useMemo that filters `findings` by excluding any finding whose `String(f.id)` is in `hiddenRowIds` (short-circuit when set is empty)
- Modify the existing `filtered` useMemo to start from `visibleFindings` instead of `findings`
- Ensure column filter dropdowns, action filter, and EXC filter all operate on the post-hide dataset
- _Requirements: 1.2, 5.1, 5.2_
- [ ]* 2.1 Write property test: hidden row filtering invariant
- **Property 1: Hidden row filtering invariant**
- Generate arbitrary arrays of finding objects and arbitrary sets of hidden Finding_IDs, compute `visibleFindings`, assert no finding in the output has an ID present in the hidden set
- **Validates: Requirements 1.1, 1.2, 4.1, 4.3, 5.1, 5.2, 6.1, 6.2**
- [x] 3. Integrate hidden rows with chart and export
- Pass `visibleFindings` (instead of `findings`) to the `ActionCoverageDonut` component's `findings` prop
- Modify the CSV export function to use the sorted/filtered visible rows (already derived from `visibleFindings` via the pipeline)
- Modify the XLSX export function to use the sorted/filtered visible rows
- Verify that chart segment click filtering operates on the visible set
- _Requirements: 4.1, 4.2, 4.3, 6.1, 6.2_
- [x] 4. Checkpoint — Verify core hide/restore pipeline
- Ensure all tests pass, ask the user if questions arise.
- [x] 5. Add selection state and bulk hide logic
- Add `selectedRowIds` state as `useState(new Set())`
- Implement `toggleRowSelection(findingId)` callback that adds/removes a string ID from the selection set
- Implement `toggleSelectAll()` callback that selects all visible sorted row IDs when not all are selected, or clears selection when all are selected
- Implement `hideSelectedRows()` callback that unions `selectedRowIds` into `hiddenRowIds`, persists, and clears the selection set
- Add a `useEffect` that prunes `selectedRowIds` to only include IDs present in the current `sorted` array whenever `sorted` changes
- _Requirements: 8.1, 8.2, 8.3, 8.5, 8.6, 8.8_
- [ ]* 5.1 Write property test: bulk hide produces union of hidden and selected sets
- **Property 5: Bulk hide produces the union of hidden and selected sets**
- Generate two arbitrary sets of Finding_ID strings (hidden and selected), simulate `hideSelectedRows`, assert the resulting hidden set equals the union and the selection set is empty
- **Validates: Requirements 8.5, 8.6**
- [ ]* 5.2 Write property test: selection is always a subset of visible rows
- **Property 6: Selection is always a subset of visible rows**
- Generate arbitrary selection and visible row sets, simulate the pruning effect, assert the resulting selection is a subset of visible row IDs
- **Validates: Requirements 8.8**
- [ ]* 5.3 Write property test: select all produces exactly the visible row ID set
- **Property 7: Select all produces exactly the visible row ID set**
- Generate an arbitrary array of finding objects representing sorted visible rows, simulate `toggleSelectAll` from empty selection, assert the selection equals the full visible ID set; toggle again, assert empty
- **Validates: Requirements 8.2, 8.3**
- [ ]* 5.4 Write property test: restore removes exactly the specified ID
- **Property 4: Restore removes exactly the specified ID**
- Generate a non-empty set of hidden Finding_IDs, pick a random element, simulate `restoreRow`, assert the result equals the original set minus that single ID
- **Validates: Requirements 3.3**
- [x] 6. Add selection checkbox column and select-all checkbox to the table
- Import `Square`, `CheckSquare`, and `MinusSquare` icons from lucide-react
- Add a fixed 36px selection checkbox column as the first column in the table header and body
- Render `Select_All_Checkbox` in the header: `CheckSquare` when all selected, `MinusSquare` when partially selected, `Square` when none selected; onClick calls `toggleSelectAll`
- Render `Selection_Checkbox` on each row: `CheckSquare` when selected, `Square` when not; onClick calls `toggleRowSelection(finding.id)`
- Style checkboxes with muted slate default color, accent blue when checked/active, matching existing icon sizing
- _Requirements: 8.1, 8.2, 8.3, 8.9, 8.11_
- [x] 7. Add per-row hide button column
- Add a fixed 36px hide button column as the second column (after selection checkbox) in the table header and body
- Render an `EyeOff` icon button on each row; onClick calls `hideRow(finding.id)`
- Style the button with 13px icon size, muted slate default color, accent blue on hover, matching existing toolbar icon patterns
- The column header cell is empty (no label)
- _Requirements: 1.1, 1.3, 1.4, 7.1_
- [x] 8. Implement BulkHideToolbar component
- Create inline `BulkHideToolbar` component accepting `count`, `onHide`, and `onClear` props
- Render "{count} rows selected" label, "Hide Selected" button with `EyeOff` icon, and "Clear" button
- Style with dark gradient background, accent border, monospace font, matching existing toolbar patterns
- Render the toolbar above the table inside the scroll container, only when `selectedRowIds.size > 0`
- Wire `onHide` to `hideSelectedRows` and `onClear` to clearing the selection set
- _Requirements: 8.4, 8.5, 8.6, 8.7, 8.10, 8.11_
- [x] 9. Checkpoint — Verify selection and bulk hide UI
- Ensure all tests pass, ask the user if questions arise.
- [x] 10. Implement RowVisibilityManager popover component
- Create inline `RowVisibilityManager` component accepting `hiddenRowIds`, `findings`, `onRestore`, and `onRestoreAll` props
- Add `open` state for popover visibility, with outside-click-to-close behavior (same pattern as existing `ColumnManager`)
- Render a toolbar button with `EyeOff` icon and "Hidden (N)" count text, styled to match the existing ColumnManager and Queue toolbar buttons (same padding, border radius, font size, uppercase text)
- When open, render a popover panel listing hidden findings by Finding_ID and title (looked up from the full `findings` array)
- Each entry has an `Eye` icon restore button that calls `onRestore(findingId)`
- Include a "Restore All" button at the bottom that calls `onRestoreAll`
- When `hiddenRowIds.size === 0`, show "No rows hidden" message in the popover
- Use dark gradient background, accent border, and box shadow matching the ColumnManager popover
- Place the button in the toolbar div adjacent to the ColumnManager button
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 7.2, 7.3_
- [x] 11. Final checkpoint — Verify complete feature integration
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- All changes are contained within `frontend/src/components/pages/ReportingPage.js` — no new files needed
- The design uses JavaScript throughout; fast-check is the PBT library
- Each task references specific requirements for traceability
- Checkpoints ensure incremental validation
- Property tests validate the 7 correctness properties defined in the design document
- Unit tests validate specific UI rendering scenarios and edge cases