Skip to content

feat(frontend/recs): per-column show/hide with Columns toolbar dropdown#326

Open
cristim wants to merge 4 commits intofeat/multicloud-web-frontendfrom
feat/recs-column-visibility
Open

feat(frontend/recs): per-column show/hide with Columns toolbar dropdown#326
cristim wants to merge 4 commits intofeat/multicloud-web-frontendfrom
feat/recs-column-visibility

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented May 6, 2026

Summary

  • Adds a "Columns ▾" button in the recommendations filter-status bar that opens a portal popover for per-column show/hide toggling
  • 8 toggleable columns: Region, Count, Term, Payment, Monthly Savings, Upfront Cost, Monthly Cost, Effective %; the 4 fixed "cell identity anchors" (Provider, Account, Service, Resource Type) are always shown (v1 — see feat(frontend/recs): per-column show/hide for the recommendations table #318 for v2 roadmap)
  • Persists selections to localStorage with a versioned schema (cudly.recs.columnVisibility.v1); unknown/stale keys are silently filtered for forward compatibility
  • Sort-hidden indicator in the filter-status bar warns when the active sort column is currently hidden
  • Full keyboard accessibility: Escape closes and returns focus; Enter/Space opens; click-outside dismisses

Implementation details

  • state.ts: hiddenColumns Set + getHiddenColumns / setHiddenColumns
  • TOGGLEABLE_COLUMNS / TOGGLEABLE_COLUMN_KEYS derived from COLUMN_DEFS (Commit 1 refactor)
  • loadColumnVisibility / saveColumnVisibility with whitelisting guard
  • resetColumnVisibilityState() exported for test isolation
  • visibleColumns() computed once per render, passed to buildListMarkup + buildVariantRowMarkup
  • summaryColspan = 1 + visible-toggleable-count (replaces hardcoded TABLE_COL_COUNT - 4)
  • mountColumnsButton: mount-once lifecycle, label shows "(N hidden)", aria-pressed reflects active state; re-syncs open-popover checkboxes on each render
  • 14 new tests via localStorageMock covering all load/save edge-cases

Test plan

  • npm run build passes with no TS errors
  • npm test -- --testPathPattern=recommendations — 181 tests pass
  • Manual: open the app, verify "Columns ▾" button appears above the table
  • Manual: toggle a column hidden — verify it disappears from header and rows
  • Manual: reload the page — verify hidden columns persist from localStorage
  • Manual: hide the sort column — verify sort-hidden note appears
  • Manual: Escape key closes popover and returns focus to the button
  • Manual: click-outside closes popover

Closes #318

Summary by CodeRabbit

  • New Features

    • Toggle individual column visibility in the recommendations table
    • Visibility preferences persist across sessions and can be reset
    • Column visibility popover in the filter/status bar; hidden-column count shown
    • Multi-variant summary rows and table headers adapt to currently visible columns; UI shows a note when sorted by a hidden column
  • Style

    • Added styles for the visibility button, popover, and related UI states
  • Tests

    • Tests for loading/saving/resetting visibility, malformed data handling, fixed vs toggleable columns, and hidden-column rendering in grouped rows

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

Adds per-column show/hide for the recommendations table: a data-driven ColumnDef model, in-memory hidden-columns state with getters/setters, localStorage load/save/reset APIs, a visibility popover UI, rendering updates to honor visible columns, styles, and tests validating persistence and behavior.

Changes

Column Visibility Feature

Layer / File(s) Summary
Data Shape & Column Model
frontend/src/recommendations.ts (ColumnDef, COLUMN_DEFS, TOGGLEABLE_COLUMNS)
Introduces ColumnDef and exports ordered, metadata-rich column definitions; derives toggleable subset and numeric column set from COLUMN_DEFS.
Cell Rendering & Row Assembly
frontend/src/recommendations.ts (renderColumnCell, buildVariantRowMarkup, buildListMarkup)
Centralizes per-column cell rendering and updates row/list builders to accept visible ColumnDef entries for consistent header/row alignment.
Summary & Header Adjustments
frontend/src/recommendations.ts
Computes visibleToggleableCols, dynamic summaryColspan, conditional multi-variant summary composition, and generates sortable <th> from visibleCols.
Persistence Layer
frontend/src/recommendations.ts (loadColumnVisibility, saveColumnVisibility, resetColumnVisibilityState)
Implements schema-versioned localStorage load/save with validation, unknown-key dropping, and a reset hook for tests.
Render Pipeline Integration
frontend/src/recommendations.ts
Seeds hidden-column state on first render, computes visibleCols once per render, and passes visibleCols into buildListMarkup for consistent rendering across header, rows, and summaries.
Visibility UI & Popover
frontend/src/recommendations.ts (visibility popover, button wiring)
Adds visibility popover state, UI, interactions, and integrates toggle button into filter/status bar; toggles call setHiddenColumns and persist via saveColumnVisibility.
State Management
frontend/src/state.ts (hiddenColumns, getHiddenColumns, setHiddenColumns)
Adds in-memory hiddenColumns set and public accessor/mutator to read/update per-column visibility while preventing hiding of fixed anchor columns.
Styling
frontend/src/styles/components.css
Adds styles for the column-visibility button, popover, rows, and .sort-hidden-note; fixes surrounding hover block endings.
Tests & Validation
frontend/src/__tests__/recommendations.test.ts
Imports new APIs/metadata, extends mocked state with getHiddenColumns/setHiddenColumns, resets mocks between tests, adds DOM assertions for hidden columns in multi-variant summaries, and a Column visibility test suite covering load/save/reset, schema tolerance, and metadata counts.

Sequence Diagram

sequenceDiagram
    participant User
    participant UI as Visibility Popover
    participant State as State Module
    participant Storage as localStorage
    participant Render as Render Engine

    User->>UI: Click "Columns" button
    UI->>State: getHiddenColumns()
    State-->>UI: current hidden set
    UI->>User: Display popover with toggles

    User->>UI: Toggle checkbox
    UI->>State: setHiddenColumns(updated set)
    State->>Storage: saveColumnVisibility(updated set)
    Storage-->>State: persisted

    UI->>Render: Request re-render
    Render->>State: getHiddenColumns()
    State-->>Render: current hidden set
    Render->>Render: Filter COLUMN_DEFS → visibleCols
    Render->>User: Updated table with visibleCols
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • #165: The PR implements the same per-column visibility feature (toggle UI, localStorage persistence, render filtering) and could be linked to this PR.

Possibly related PRs

  • LeanerCloud/CUDly#322: Touches the same recommendations column model and state; related changes overlap in frontend/src/recommendations.ts and frontend/src/state.ts.
  • LeanerCloud/CUDly#242: Related—adds column changes and helpers that interact with the new COLUMN_DEFS model.

Poem

🐰 A narrow-eyed rabbit found columns too wide,
It nudged a small menu where toggles reside.
With checkboxes checked and localStorage kept,
The table now breathes—no more clutter to step.
Hooray for tidy rows on each delightful stride!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature added: per-column visibility toggling via a Columns dropdown in the recommendations toolbar.
Linked Issues check ✅ Passed The implementation addresses all coding requirements from issue #318: column visibility APIs, toggleable column set, persistence via versioned localStorage, hidden-column indicator, keyboard accessibility, and comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing issue #318 column visibility feature; no unrelated modifications detected across the recommendations module, state management, or test files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/recs-column-visibility

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Comment @coderabbitai help to get the list of available commands and usage tips.

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/low Minor harm urgency/this-quarter Within the quarter impact/many Affects most users effort/m Days type/feat New capability labels May 6, 2026
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 6, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/recommendations.ts (1)

2068-2121: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Hidden toggleable columns still appear in collapsed summary rows

Line 2120 and Line 2121 always render Region / Savings / Upfront / Term text even when those columns are hidden, so hide/show is not consistently applied across all row <tr> variants.

Proposed fix
   const visibleToggleableCols = visibleCols.filter((c) => TOGGLEABLE_COLUMN_KEYS.has(c.key));
   const summaryColspan = 1 + visibleToggleableCols.length;
+  const visibleKeys = new Set(visibleCols.map((c) => c.key));

@@
-    rows.push(`
+    const identityParts: string[] = [
+      `${escapeHtml(rep.resource_type)}${rep.engine ? ` (${escapeHtml(rep.engine)})` : ''}`,
+    ];
+    if (visibleKeys.has('region')) identityParts.push(escapeHtml(rep.region));
+    identityParts.push(`${variants.length} variants`);
+
+    const rangeParts: string[] = [];
+    if (visibleKeys.has('savings')) rangeParts.push(`${savingsDisplay}/mo`);
+    if (visibleKeys.has('upfront_cost')) rangeParts.push(`upfront: ${upfrontDisplay}`);
+    if (visibleKeys.has('term')) rangeParts.push(`term: ${termDisplay}`);
+
+    rows.push(`
   <tr class="rec-cell-summary-row" data-cell-key="${escapeHtml(key)}">
@@
     <td colspan="${summaryColspan}" class="rec-cell-summary-content">
-      <span class="rec-cell-identity">${escapeHtml(rep.resource_type)}${rep.engine ? ` (${escapeHtml(rep.engine)})` : ''} &mdash; ${escapeHtml(rep.region)} &mdash; ${variants.length} variants</span>
-      <span class="rec-cell-range">${savingsDisplay} &middot; upfront: ${upfrontDisplay} &middot; term: ${termDisplay}</span>
+      <span class="rec-cell-identity">${identityParts.join(' &mdash; ')}</span>
+      ${rangeParts.length > 0 ? `<span class="rec-cell-range">${rangeParts.join(' &middot; ')}</span>` : ''}
     </td>
   </tr>`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/recommendations.ts` around lines 2068 - 2121, Summary: The
collapsed summary row always renders region/savings/upfront/term text regardless
of column visibility; update rec-cell-summary-content rendering to respect
visibleCols/TOGGLEABLE_COLUMN_KEYS and summaryColspan. Fix: inside the
multi-variant branch where you build the summary row (code using variables
summaryColspan, summary, savingsDisplay, upfrontDisplay, termDisplay, chevron,
and rep), conditionally include the region span and the rec-cell-range elements
only if their corresponding column keys are present in visibleCols (use
visibleCols.some(c => c.key === '<key>') or TOGGLEABLE_COLUMN_KEYS.has(...) on
the right keys), and recompute summaryColspan based on visibleToggleableCols so
colspan matches; ensure selectedVariant logic is preserved but only injects
savings/upfront/term markup when those columns are visible.
🧹 Nitpick comments (1)
frontend/src/state.ts (1)

167-169: 💤 Low value

Return a defensive copy for consistency with other getters.

Other state getters in this file (getSelectedRecommendationIDs, getVisibleRecommendations, getRecommendationsColumnFilters) return defensive copies to prevent callers from mutating internal state. getHiddenColumns returns the internal Set reference directly. While ReadonlySet prevents TypeScript-level mutations, at runtime a caller could bypass this via casting.

Suggested fix
 export function getHiddenColumns(): ReadonlySet<RecommendationsColumnId> {
-  return hiddenColumns;
+  return new Set(hiddenColumns);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/state.ts` around lines 167 - 169, getHiddenColumns currently
returns the internal Set reference (hiddenColumns) which can be mutated at
runtime; change it to return a defensive copy by constructing and returning a
new Set from hiddenColumns (while preserving the declared return type
ReadonlySet<RecommendationsColumnId>) so callers receive a copy and cannot
mutate internal state; update the return in getHiddenColumns to return a new Set
created from hiddenColumns.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/recommendations.ts`:
- Around line 1458-1548: The tab deactivation MutationObserver currently only
calls closePopover(), leaving the visibility popover (managed by
openVisibilityPopover and closeVisibilityPopover) open after navigation; update
the tab deactivation/observer callback to also call closeVisibilityPopover()
wherever closePopover() is invoked so the visibility popover is closed and its
listeners removed when the recommendations tab deactivates. Ensure you reference
the openVisibilityPopover and closeVisibilityPopover symbols when making the
change so the visibility popover lifecycle is cleaned up consistently.

---

Outside diff comments:
In `@frontend/src/recommendations.ts`:
- Around line 2068-2121: Summary: The collapsed summary row always renders
region/savings/upfront/term text regardless of column visibility; update
rec-cell-summary-content rendering to respect visibleCols/TOGGLEABLE_COLUMN_KEYS
and summaryColspan. Fix: inside the multi-variant branch where you build the
summary row (code using variables summaryColspan, summary, savingsDisplay,
upfrontDisplay, termDisplay, chevron, and rep), conditionally include the region
span and the rec-cell-range elements only if their corresponding column keys are
present in visibleCols (use visibleCols.some(c => c.key === '<key>') or
TOGGLEABLE_COLUMN_KEYS.has(...) on the right keys), and recompute summaryColspan
based on visibleToggleableCols so colspan matches; ensure selectedVariant logic
is preserved but only injects savings/upfront/term markup when those columns are
visible.

---

Nitpick comments:
In `@frontend/src/state.ts`:
- Around line 167-169: getHiddenColumns currently returns the internal Set
reference (hiddenColumns) which can be mutated at runtime; change it to return a
defensive copy by constructing and returning a new Set from hiddenColumns (while
preserving the declared return type ReadonlySet<RecommendationsColumnId>) so
callers receive a copy and cannot mutate internal state; update the return in
getHiddenColumns to return a new Set created from hiddenColumns.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: edcab9ca-4520-4c48-9f44-aea3cb656a7a

📥 Commits

Reviewing files that changed from the base of the PR and between 7032056 and 3296a1f.

📒 Files selected for processing (4)
  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/recommendations.ts
  • frontend/src/state.ts
  • frontend/src/styles/components.css

Comment thread frontend/src/recommendations.ts
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 6, 2026

Addressed the latest CodeRabbit comments in 6eeb34b:

  • Collapsed recommendation summary rows now only include values for visible columns.
  • Tab deactivation closes both filter and column visibility popovers.
  • Hidden-column state getter now returns a defensive copy.

Verification:

  • npm run typecheck
  • npx jest recommendations.test.ts --runInBand --watchman=false --coverage=false
  • npm test -- --runInBand --watchman=false
  • npm run build
  • SKIP=terraform_validate pre-commit run --all-files

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Tip

For best results, initiate chat on the files or code changes.

@cristim: Thanks for the thorough update and verification! The three fixes look well-considered:

  • Collapsed summary rows — scoping values to visible columns ensures the colspan math stays consistent with the rendered header.
  • Tab deactivation — closing both popovers on tab switch prevents stale UI state when switching tabs.
  • Defensive copy in getHiddenColumns — returning a copy of the Set prevents external callers from mutating internal state directly, which is a good defensive practice.

Triggering a fresh review of the latest commit now.

[review]

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 6, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/__tests__/recommendations.test.ts`:
- Line 3627: The test sets a sticky mock implementation for
state.getHiddenColumns via (state.getHiddenColumns as
jest.Mock).mockReturnValue(...) which isn't cleared by jest.clearAllMocks();
update the describe block's beforeEach to explicitly reset or re-mock
getHiddenColumns to return a new empty Set (e.g., call (state.getHiddenColumns
as jest.Mock).mockReturnValue(new Set()) or .mockReset() and then
.mockReturnValue(...)) so loadRecommendations() and other tests don't inherit
the previous sticky return value.

In `@frontend/src/recommendations.ts`:
- Around line 1545-1549: The deferred setTimeout may try to add a null
visOutsideClickHandler because closeVisibilityPopover() can clear it before the
callback runs; within openVisibilityPopover_ capture the current
visOutsideClickHandler into a local const (e.g., const handler =
visOutsideClickHandler) and use that captured reference inside the setTimeout
callback, and guard adding only if handler is non-null before calling
document.addEventListener; this preserves the intended tick delay while avoiding
the race and the non-null assertion.

In `@frontend/src/state.ts`:
- Around line 171-172: setHiddenColumns currently accepts any
RecommendationsColumnId which allows callers to hide fixed anchor columns;
change it to enforce the same whitelist used by loadColumnVisibility by either
(a) narrowing the parameter type to the existing ToggleableColumnId (or create
one) so callers cannot pass fixed columns, or (b) filter the incoming hidden set
inside setHiddenColumns to remove provider, account, service, and resource_type
before assigning hiddenColumns; update the function signature or perform the
filter and keep the assignment to hiddenColumns = new Set(filtered) so fixed
columns remain visible.
🪄 Autofix (Beta)

✅ Autofix completed


ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: efa60bc2-361a-4e0b-be9c-f641b47cfaa8

📥 Commits

Reviewing files that changed from the base of the PR and between 3296a1f and 6eeb34b.

📒 Files selected for processing (3)
  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/recommendations.ts
  • frontend/src/state.ts

Comment thread frontend/src/__tests__/recommendations.test.ts
Comment thread frontend/src/recommendations.ts
Comment thread frontend/src/state.ts Outdated
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Fixes Applied Successfully

Fixed 3 file(s) based on 3 unresolved review comments.

Files modified:

  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/recommendations.ts
  • frontend/src/state.ts

Commit: 863581620c394dc82a0c2c774c68521531350a7b

The changes have been pushed to the feat/recs-column-visibility branch.

Time taken: 3m 50s

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 6, 2026

Rebased onto current feat/multicloud-web-frontend (which now includes #322's on_demand_monthly column). Conflicts were:

  • recommendations.ts: 4 conflict regions where the column-array consolidation refactor met the new column-map entries from feat(frontend/recs): add 'On-Demand Monthly' column to recommendations table #322. Resolved by adding on_demand_monthly to COLUMN_DEFS, dropping the obsolete FILTERABLE_COLUMNS static array (now derived), and adding an on_demand_monthly case to renderColumnCell (uses onDemandMonthly(rec) directly instead of pre-computing odm/odmText in buildVariantRowMarkup).
  • recommendations.test.ts: import-line conflict, kept both new symbol sets. Updated the COLUMN_DEFS.length assertion from 12 → 13 to account for the new column.

Re: the 3 actionable findings from the prior review — verified each against current code; all already addressed:

  1. Visibility popover not closed on tab deactivaterecommendations.ts:1492-1499 already calls both closePopover() and closeVisibilityPopover() when the tab loses active.
  2. Race condition on visOutsideClickHandlerrecommendations.ts:1606-1611 already captures the handler in a closure const + null-checks before addEventListener.
  3. setHiddenColumns invariantstate.ts:171-176 already filters out the fixed anchor columns (provider/account/service/resource_type) before assigning to hiddenColumns.

Plus the test sticky-mock concern — recommendations.test.ts:3935-3936 already resets state.getHiddenColumns to new Set() in beforeEach after jest.clearAllMocks().

Full suite: 1551/1551 passing. Build clean.

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Tip

For best results, initiate chat on the files or code changes.

@cristim: Thanks for the detailed rebase summary — the conflict resolutions look clean and consistent with the column-driven design:

  • on_demand_monthly in COLUMN_DEFS — adding it here keeps the single source of truth intact and avoids a separate static array entry.
  • Dropping FILTERABLE_COLUMNS static array — deriving it from COLUMN_DEFS is the right call; it eliminates a future divergence risk when more columns are added.
  • renderColumnCell case for on_demand_monthly — using onDemandMonthly(rec) directly (rather than pre-computing in buildVariantRowMarkup) is consistent with how other columns are handled in the cell renderer.
  • COLUMN_DEFS.length assertion 12 → 13 — straightforward update to match the new column count.

Good to see the three prior findings are confirmed present in current code, and the test beforeEach reset guards against sticky mock state across the column-visibility suite. Triggering a fresh review now.

[review]

cristim and others added 4 commits May 6, 2026 22:17
Replace three separate column enumerations (SORT_HEADER_LABELS, NUMERIC_COLUMNS,
FILTERABLE_COLUMNS) with a single COLUMN_DEFS array that carries key, label, and
kind (numeric | categorical) for each of the 12 data columns.

Derive SORT_HEADER_LABELS and NUMERIC_COLUMNS from COLUMN_DEFS so future column
additions/removals are a one-line edit in COLUMN_DEFS with no separate label or
type maintenance.

Introduce renderColumnCell() that maps a column key to its <td> HTML, and
rework buildVariantRowMarkup to iterate COLUMN_DEFS (or a caller-provided subset)
rather than a hardcoded template literal.  The optional `cols` parameter (default
COLUMN_DEFS) is the extension point for the per-column visibility feature (#318)
landing in the next commit.

Replace the hardcoded TABLE_COL_COUNT = 13 constant with `1 + COLUMN_DEFS.length`
so the summary-row colspan stays correct automatically.

Behaviour is unchanged: same columns, same order, same HTML output.  All 1515
existing tests pass.
…opdown

Add a "Columns ▾" button in the recommendations filter-status bar that
opens a portal popover with per-column checkboxes for the 8 toggleable
columns (region, count, term, payment, savings, upfront_cost,
monthly_cost, effective_savings_pct). Provider / Account / Service /
Resource Type are "cell identity anchors" and always shown (v1).

- state.ts: hiddenColumns Set + getHiddenColumns/setHiddenColumns
- TOGGLEABLE_COLUMNS / TOGGLEABLE_COLUMN_KEYS from COLUMN_DEFS
- loadColumnVisibility / saveColumnVisibility with versioned localStorage
  schema (cudly.recs.columnVisibility.v1, schemaVersion: 1); unknown
  keys are whitelisted-out for forward compatibility
- resetColumnVisibilityState() exported for tests (seed flag + state)
- visibleColumns() computed once per renderRecommendationsList call,
  passed through to buildListMarkup + buildVariantRowMarkup
- summaryColspan = 1 + visible-toggleable-count (replaces hardcoded
  TABLE_COL_COUNT - 4; TABLE_COL_COUNT removed as no longer needed)
- mountColumnsButton: mount-once lifecycle, label shows "(N hidden)",
  aria-pressed reflects active state; re-syncs open-popover checkboxes
- Sort-hidden indicator in filter-status bar warns when the sort column
  is currently hidden
- Full keyboard accessibility: Escape closes and returns focus; Enter/
  Space opens; click-outside dismisses
- 14 new tests covering load/save/edge-cases via localStorageMock

Closes #318
Update recommendation summary rows to only include values for visible
columns, close the column visibility popover when the tab deactivates,
and return a defensive copy of hidden column state.
Fixed 3 file(s) based on 3 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
@cristim cristim force-pushed the feat/recs-column-visibility branch from 4641b47 to 1a1e78e Compare May 6, 2026 20:21
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 6, 2026

Rebased again — #328 (cost-period selector) merged into base, which created fresh conflicts. Resolved across 3 files:

frontend/src/recommendations.ts

  • COLUMN_DEFS metadata kept; STATIC_COLUMN_LABELS is now derived from it (filtering out the 3 period-varying cost columns: savings, monthly_cost, on_demand_monthly) so the source-of-truth still drives display while getColumnLabel() keeps owning the per-period suffix logic.
  • renderColumnCell is now period-aware: period: CostPeriod threaded through ctx, and the savings / monthly_cost / on_demand_monthly cases call formatCostForPeriod(value, ctx.period) instead of raw formatCurrency. upfront_cost stays period-invariant.
  • buildVariantRowMarkup resolves the period once per row and passes it via ctx (avoids re-reading state.getCostPeriod() on every cell).
  • The visibility filter-status-bar block now mounts BOTH the cost-period selector (feat(frontend/recs): cost-period selector (hourly/daily/monthly/yearly), default monthly #319) AND the "Columns ▾" button (feat(frontend/recs): per-column show/hide for the recommendations table #318); sort-hidden indicator uses getColumnLabel(sortCol, currentPeriod) so the note label reflects the active period.

frontend/src/state.ts

  • Cost-period state (#319) and column-visibility state (#318) are both kept as separate, orthogonal sections.

frontend/src/__tests__/recommendations.test.ts

Note: the prior fixup commit fix(test): update COLUMN_DEFS-length assertion was dropped during rebase ("patch contents already upstream") — the same change is now part of the visibility-feature commit instead.

Full suite: 1592/1592 passing. Build clean.

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Tip

For best results, initiate chat on the files or code changes.

@cristim: The rebase resolutions look clean and well-reasoned:

  • STATIC_COLUMN_LABELS derived from COLUMN_DEFS — filtering out the 3 period-varying cost columns (savings, monthly_cost, on_demand_monthly) is the right call; it keeps COLUMN_DEFS as the single source of truth while correctly deferring per-period label suffixes to getColumnLabel().
  • Period threaded through ctx — resolving getCostPeriod() once per row in buildVariantRowMarkup and passing it via ctx is a clean optimization; it avoids repeated state reads per cell and makes the data flow explicit.
  • upfront_cost staying period-invariant — correct, since upfront is a one-time cost that doesn't scale with monthly/annual period selection.
  • sort-hidden indicator using getColumnLabel(sortCol, currentPeriod) — good catch; the hidden-column note label should reflect the active period so it's accurate (e.g., "Monthly Savings" vs "Annual Savings").
  • Orthogonal state sections in state.ts — keeping cost-period (#319) and column-visibility (#318) as separate sections is good separation of concerns and avoids entanglement between the two features.
  • Coexisting mock factories and test suites — 1592/1592 passing with both feature mock sets in place is a solid signal.

Triggering a fresh review now.

[review]

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 7, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
frontend/src/recommendations.ts (1)

1756-1761: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent stale outside-click handler attachment after fast close.

At Line 1757, the deferred callback can still attach an old handler after closeVisibilityPopover() ran, which leaves an untracked document listener and can make later popovers close unexpectedly.

Proposed fix
   const handler = visOutsideClickHandler;
   setTimeout(() => {
-    if (handler) {
+    if (handler && openVisibilityPopover && visOutsideClickHandler === handler) {
       document.addEventListener('mousedown', handler);
     }
   }, 0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/recommendations.ts` around lines 1756 - 1761, The deferred
attachment may add a stale visOutsideClickHandler after closeVisibilityPopover()
runs; to fix, capture the handler as you do (visOutsideClickHandler) but before
calling document.addEventListener verify that the current visOutsideClickHandler
still equals that captured handler (or that the popover is still open) so you
don't attach an outdated listener—update the setTimeout callback in the block
that sets handler to perform this equality/check against visOutsideClickHandler
(and ensure closeVisibilityPopover clears visOutsideClickHandler when closing).
🧹 Nitpick comments (2)
frontend/src/__tests__/recommendations.test.ts (1)

4752-4767: ⚡ Quick win

Tighten the toggleable-columns assertion to pin the full contract.

This test currently checks exclusions and several inclusions, but not the full set size or on_demand_monthly. Pinning the exact set will catch future drift/rebase misses earlier.

Suggested test hardening
   test('TOGGLEABLE_COLUMNS excludes fixed identity columns', () => {
     const keys = TOGGLEABLE_COLUMNS.map((c) => c.key);
@@
       expect(keys).toContain('monthly_cost');
+      expect(keys).toContain('on_demand_monthly');
       expect(keys).toContain('effective_savings_pct');
+      expect(keys.length).toBe(9);
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/__tests__/recommendations.test.ts` around lines 4752 - 4767, The
test 'TOGGLEABLE_COLUMNS excludes fixed identity columns' currently asserts a
few inclusions/exclusions but not the full contract; update it to assert the
exact toggleable keys set by building an expectedKeys array (including
on_demand_monthly plus the other toggleables like region, count, term, payment,
savings, upfront_cost, monthly_cost, effective_savings_pct) and then assert
equality with TOGGLEABLE_COLUMNS.map(c => c.key) (either by comparing sorted
arrays or checking length and set equality) so the test pins the full contract
for TOGGLEABLE_COLUMNS.
frontend/src/recommendations.ts (1)

1660-1663: ⚡ Quick win

Sync aria-expanded/aria-haspopup for the Columns trigger.

The popover trigger currently uses aria-pressed, but it doesn’t expose open/closed dialog state. Adding aria-haspopup="dialog" and toggling aria-expanded on open/close improves keyboard/screen-reader behavior.

Proposed refactor
 interface VisibilityPopoverState {
   el: HTMLDivElement;
   checkboxes: Map<state.RecommendationsColumnId, HTMLInputElement>;
+  trigger: HTMLElement;
 }
@@
 function closeVisibilityPopover(): void {
   if (!openVisibilityPopover) return;
-  openVisibilityPopover.el.remove();
+  const { el, trigger } = openVisibilityPopover;
+  el.remove();
+  trigger.setAttribute('aria-expanded', 'false');
   openVisibilityPopover = null;
@@
 function openVisibilityPopover_(anchor: HTMLElement): void {
@@
-  openVisibilityPopover = { el: popover, checkboxes };
+  openVisibilityPopover = { el: popover, checkboxes, trigger: anchor };
+  anchor.setAttribute('aria-expanded', 'true');
   positionPopover(popover, anchor);
@@
   if (!btn) {
@@
     btn = document.createElement('button');
     btn.type = 'button';
     btn.className = 'column-visibility-btn';
+    btn.setAttribute('aria-haspopup', 'dialog');
+    btn.setAttribute('aria-expanded', 'false');
     bar.appendChild(btn);

Also applies to: 1669-1681, 1683-1697, 1766-1787

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/recommendations.ts` around lines 1660 - 1663, The Columns
popover trigger should expose dialog state to assistive tech: on the popover
trigger element (the element associated with VisibilityPopoverState.el / the
Columns trigger in frontend/src/recommendations.ts) add aria-haspopup="dialog"
and ensure aria-expanded is set to "true" when the popover opens and "false"
when it closes; update the open/close/toggle handlers that show the popover
(e.g., the functions that create/show and hide the VisibilityPopoverState.el or
the toggle handler currently using aria-pressed) to set aria-expanded
accordingly (and you may stop relying on aria-pressed for open state if
present). Ensure both the initial render of the trigger and every path that
hides/shows the popover update aria-expanded so screen readers receive
consistent state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/styles/components.css`:
- Around line 907-910: The .recommendations-filter-status .sort-hidden-note rule
uses low-contrast color `#888` and small font-size; update the CSS to use a darker
color token/value (e.g., `#666` or your design system's dark-muted token) so the
note meets readability/accessibility contrast requirements while keeping the
existing font-size and font-style; locate the rule for
.recommendations-filter-status .sort-hidden-note and replace the color value
accordingly.

---

Duplicate comments:
In `@frontend/src/recommendations.ts`:
- Around line 1756-1761: The deferred attachment may add a stale
visOutsideClickHandler after closeVisibilityPopover() runs; to fix, capture the
handler as you do (visOutsideClickHandler) but before calling
document.addEventListener verify that the current visOutsideClickHandler still
equals that captured handler (or that the popover is still open) so you don't
attach an outdated listener—update the setTimeout callback in the block that
sets handler to perform this equality/check against visOutsideClickHandler (and
ensure closeVisibilityPopover clears visOutsideClickHandler when closing).

---

Nitpick comments:
In `@frontend/src/__tests__/recommendations.test.ts`:
- Around line 4752-4767: The test 'TOGGLEABLE_COLUMNS excludes fixed identity
columns' currently asserts a few inclusions/exclusions but not the full
contract; update it to assert the exact toggleable keys set by building an
expectedKeys array (including on_demand_monthly plus the other toggleables like
region, count, term, payment, savings, upfront_cost, monthly_cost,
effective_savings_pct) and then assert equality with TOGGLEABLE_COLUMNS.map(c =>
c.key) (either by comparing sorted arrays or checking length and set equality)
so the test pins the full contract for TOGGLEABLE_COLUMNS.

In `@frontend/src/recommendations.ts`:
- Around line 1660-1663: The Columns popover trigger should expose dialog state
to assistive tech: on the popover trigger element (the element associated with
VisibilityPopoverState.el / the Columns trigger in
frontend/src/recommendations.ts) add aria-haspopup="dialog" and ensure
aria-expanded is set to "true" when the popover opens and "false" when it
closes; update the open/close/toggle handlers that show the popover (e.g., the
functions that create/show and hide the VisibilityPopoverState.el or the toggle
handler currently using aria-pressed) to set aria-expanded accordingly (and you
may stop relying on aria-pressed for open state if present). Ensure both the
initial render of the trigger and every path that hides/shows the popover update
aria-expanded so screen readers receive consistent state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8491b282-a5e2-4a18-a952-4d8b09eb1712

📥 Commits

Reviewing files that changed from the base of the PR and between 6eeb34b and 1a1e78e.

📒 Files selected for processing (4)
  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/recommendations.ts
  • frontend/src/state.ts
  • frontend/src/styles/components.css
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/src/state.ts

Comment on lines +907 to +910
.recommendations-filter-status .sort-hidden-note {
font-size: 0.82em;
color: #888;
font-style: italic;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Increase contrast for the sort-hidden note text.

At Line 908–Line 910, the text is small (0.82em) and uses #888, which is likely too low-contrast on white for normal text. Use a darker token/value (for example #666) to meet readability/accessibility expectations.

Suggested change
 .recommendations-filter-status .sort-hidden-note {
     font-size: 0.82em;
-    color: `#888`;
+    color: `#666`;
     font-style: italic;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.recommendations-filter-status .sort-hidden-note {
font-size: 0.82em;
color: #888;
font-style: italic;
.recommendations-filter-status .sort-hidden-note {
font-size: 0.82em;
color: `#666`;
font-style: italic;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/styles/components.css` around lines 907 - 910, The
.recommendations-filter-status .sort-hidden-note rule uses low-contrast color
`#888` and small font-size; update the CSS to use a darker color token/value
(e.g., `#666` or your design system's dark-muted token) so the note meets
readability/accessibility contrast requirements while keeping the existing
font-size and font-style; locate the rule for .recommendations-filter-status
.sort-hidden-note and replace the color value accordingly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p2 Backlog-worthy severity/low Minor harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant