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:
400
.kiro/specs/reporting-row-visibility/design.md
Normal file
400
.kiro/specs/reporting-row-visibility/design.md
Normal 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
|
||||
Reference in New Issue
Block a user