Skip to content

feat(frontend/recs): row-click toggles selection on Opportunities (closes #344)#352

Merged
cristim merged 3 commits into
feat/multicloud-web-frontendfrom
feat/issue-344-row-click-selection
May 13, 2026
Merged

feat(frontend/recs): row-click toggles selection on Opportunities (closes #344)#352
cristim merged 3 commits into
feat/multicloud-web-frontendfrom
feat/issue-344-row-click-selection

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented May 13, 2026

Summary

Closes the last task in the issue-344 phase-2 plan — row-click toggles selection on Opportunities, replacing the previous row-click → detail-drawer behaviour.

The original T4 (right-side Plan-builder drawer) was dropped in plan review on 2026-05-13: every payload the drawer could carry (per-rec payment-option comparison, target/baseline tracking) is explicitly backend-deferred, so the drawer would have shipped as a prettier copy of the existing bottom action-box with no new information. The detail drawer that already opened on row-click had the same character: confidence + provenance duplicated the table, and usage_history is empty by design until the collector wiring follow-up lands (known_issues/28).

What changes

Behaviour:

Code:

  • frontend/src/recommendations.ts: -227 lines (openDetailDrawer + fetchRecommendationDetail + detailFetchCache + clearRecommendationDetailCache + renderUsageSparkline removed); +13 lines for the row-click toggle handler.
  • frontend/src/styles/components.css: -84 lines (.detail-drawer* + .confidence-* rules).
  • frontend/src/__tests__/recommendations.test.ts: -136 lines (5 drawer tests removed) + +84 lines (4 row-click selection tests).
  • frontend/src/api/recommendations.ts: untouchedgetRecommendationDetail stays exported so the backend endpoint contract is preserved. Resurrecting the drawer if we ever want it back is a git revert of this commit.

New tests (4)

  1. Clicking a non-interactive cell on a row toggles the row checkbox.
  2. Clicking a row a second time unselects it.
  3. Row-click does NOT trigger when the click originates on an interactive descendant.
  4. Clicking the checkbox itself does not double-toggle (native click handles it; the row handler must skip the input).

Verification

  • Tests: 1629 pass / 1 skipped. 2 failing in users.test.ts (openCreateUserModal › should make password required, saveUser › should validate empty password for new user) pre-date this branch — they came in with PR feat(auth): invite users without a password and let them set it on first login #348 (invite users without password). Verified by running the failing tests on a clean stash of this branch's base.
  • Coverage: 80.70% stmts / 68.07% branches / 70.93% funcs / 82.46% lines. Slight dip from the post-T3 baseline (80.93 / 68.21 / 71.04 / 82.70) as expected when removing tested code paths; still inside parity gates.
  • Build: 517 KiB (down from 522 KiB after T3 — the deleted drawer assets shaved ~5 KiB).

Closes #344.

Summary by CodeRabbit

Release Notes

Changes

  • Row click behavior updated: selecting a recommendation row now toggles its checkbox instead of opening a detail drawer
  • Detail drawer view has been removed from the interface
  • Confidence badge indicators have been removed
  • Row selection properly handles interactions with interactive elements within rows

Review Change Stack

…oses #344)

Replace the row-click → detail-drawer behaviour (issue #44) with
row-click → toggle selection. Clicking anywhere on a recommendation
row's body now toggles the row's checkbox + dispatches the existing
change handler (which enforces the one-variant-per-cell radio
behaviour from #224). Clicks on interactive descendants (input /
button / a / label / select / [data-action]) still flow through to
their own handlers, untouched.

This closes the last task in the issue-344 phase-2 plan. The original
T4 (right-side Plan-builder drawer) was dropped in plan review: every
payload the drawer could carry (per-rec payment-option comparison,
target/baseline tracking) is explicitly backend-deferred, so the
drawer would have shipped as a prettier copy of the existing bottom
action-box with no new information. The detail drawer that already
opened on row-click had the same character — confidence + provenance
duplicated the table, and usage history is empty by design until the
collector wiring lands (known_issues/28).

Net changes:
- recommendations.ts: -227 lines (openDetailDrawer + fetchRecommend-
  ationDetail + detailFetchCache + clearRecommendationDetailCache +
  renderUsageSparkline removed); +13 lines (row-click toggle handler).
- styles/components.css: -84 lines (.detail-drawer*, .confidence-*).
- recommendations.test.ts: -136 lines (5 drawer tests removed) +
  +84 lines (4 row-click selection tests covering: toggle on
  non-interactive cell, second click unselects, interactive
  descendant click does NOT toggle, native checkbox click doesn't
  double-toggle).
- api/recommendations.ts: untouched. getRecommendationDetail stays
  exported so the backend endpoint contract is preserved; resurrecting
  the drawer if needed is a git revert of this commit.

Verification: 1629/1632 tests pass / 1 skipped (2 unrelated failures
in users.test.ts pre-date this branch — they came in with PR #348).
Coverage 80.70% stmts / 82.46% lines (slight dip from 80.93/82.70 as
expected when removing tested code paths; well within parity gates).
Build 517 KiB (down from 522 KiB after T3; the deleted drawer assets
shaved ~5 KiB).
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 991dcebd-1ab0-4e8c-92ad-8a62d7b1d68d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR removes the recommendation detail-drawer feature and changes row-click behavior from opening a drawer to toggling the Include checkbox selection. This includes removing detail-fetch logic, caching, drawer rendering, exported cache helpers, and all associated styling, while updating tests to validate the new selection-toggle interaction.

Changes

Detail Drawer Removal and Selection Interaction Replacement

Layer / File(s) Summary
Remove detail-drawer implementation
frontend/src/recommendations.ts
The module no longer imports getRecommendationDetail or its type. Removes the entire drawer implementation: openDetailDrawer, per-id detailFetchCache and fetchRecommendationDetail memoization, exported clearRecommendationDetailCache function, and renderUsageSparkline helper.
Replace row-click behavior with checkbox selection
frontend/src/recommendations.ts
Row clicks now toggle the Include checkbox by flipping checked state and dispatching change event, while ignoring clicks from checkboxes and other interactive descendants. Prior drawer-opening logic is removed.
Clean up removed styles
frontend/src/styles/components.css
Removes CSS blocks for detail-drawer UI (backdrop, drawer, header, close button, fields, note) and confidence-badge components (base styles, high/medium/low variants, muted-note).
Update test coverage for selection behavior
frontend/src/__tests__/recommendations.test.ts
Removes import of clearRecommendationDetailCache and adds other utility imports. Replaces drawer-open and detail-fetch tests with a new selection-toggle test suite that validates checkbox toggling via row click, unselecting on second click, and preventing selection via interactive descendants.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • LeanerCloud/CUDly#275: Both PRs make user checkbox selection authoritative; this PR enables row clicks to toggle selection, while #275 uses getSelectedRecommendationIDs to gate Purchase/Plan button behavior.
  • LeanerCloud/CUDly#325: Both extend recommendation row selection semantics via checkbox state, replacing drawer interaction in this PR vs adding skip checkboxes and purchase-modal filtering in #325.
  • LeanerCloud/CUDly#80: Overlaps directly—this PR removes the detail drawer and clearRecommendationDetailCache, while #80 added the GET /api/recommendations/:id/detail endpoint and drawer wiring.

Suggested labels

urgency/this-sprint, impact/many, effort/s

Poem

🐰 A drawer once slid, now checkboxes reign,
Click a row, select—no detail pain!
CSS fades, old sparklines away,
Selection reigns supreme today! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% 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 change: row-click now toggles selection on Opportunities instead of opening a detail drawer, and it references the closing issue #344.
Linked Issues check ✅ Passed This PR implements item 1 from issue #344 (plan-builder drawer prep by removing the old detail drawer), but defers the actual drawer UI. The row-click selection logic is a prerequisite for the drawer. All code changes align with the stated objective of replacing detail-drawer with selection-based row interaction.
Out of Scope Changes check ✅ Passed All changes are scoped to removing detail-drawer infrastructure and implementing row-click selection on recommendations—directly aligned with #344 phase-2 item 1. No unrelated changes to other modules, filters, skeletons, or business logic detected.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-344-row-click-selection

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/medium Moderate harm urgency/this-quarter Within the quarter impact/all-users Affects every user effort/m Days type/feat New capability labels May 13, 2026
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 13, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 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.

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 13, 2026

CI run 25823020676 (pre-commit) passed on c372468.

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

🤖 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 3287-3289: The click handler on tr (tr.addEventListener) unsafely
casts e.target to HTMLElement and calls closest(), which can throw for text
nodes; update the handler to first check that e.target is an Element (e.g.,
using "if (!(e.target instanceof Element)) return;") before assigning to target
or calling target.closest(...) so only Element nodes invoke closest and avoid
TypeError when clicking plain text.
🪄 Autofix (Beta)

✅ Autofix completed


ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 637ad69f-db54-422b-bebe-b0525116dfed

📥 Commits

Reviewing files that changed from the base of the PR and between 4e2c57f and c372468.

📒 Files selected for processing (3)
  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/recommendations.ts
  • frontend/src/styles/components.css
💤 Files with no reviewable changes (1)
  • frontend/src/styles/components.css

Comment on lines 3287 to +3289
tr.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.closest('input[type="checkbox"], button, a')) return;
const id = tr.dataset['recId'] || '';
const rec = recommendations.find((r) => r.id === id);
if (rec) openDetailDrawer(rec);
if (target.closest('input, button, a, label, select, [data-action]')) return;
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

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and examine the recommendations.ts file
fd -t f "recommendations.ts" frontend/

Repository: LeanerCloud/CUDly

Length of output: 129


🏁 Script executed:

# Check file size first
wc -l frontend/src/recommendations.ts

Repository: LeanerCloud/CUDly

Length of output: 98


🏁 Script executed:

# Read the relevant section of the file
sed -n '3280,3300p' frontend/src/recommendations.ts

Repository: LeanerCloud/CUDly

Length of output: 1126


🌐 Web query:

Element.closest() method behavior on text nodes DOM specification

💡 Result:

Element.closest is defined on the Element interface, so when you have a Text node (Node.TEXT_NODE, nodeType 3) there is no Element.closest method to call; you must use the Text node’s parent/ancestors (which are Elements) instead. [1][2] Behavior when a “text node” is involved: 1) If you call closest on an actual Element, closest traverses that element and its ancestor Elements toward the document root until it finds one that matches the selector, returning that Element (or itself) or null if no match exists. [3] 2) If your code’s “current node” is a Text node (for example, event.target might be a Text node in some cases, or you obtained a node from a traversal), you can’t directly run closest because Text nodes are not Elements and don’t implement Element.closest. [1][2] 3) There is no requirement for closest to consider non-Element nodes (Text, Comment, etc.) in its traversal; the traversal is through Elements/Element ancestors only. [3] Practical patterns (safe for text nodes): - Start from the text node’s parentElement: let el = textNode.parentElement?.closest(selector); - Or use parentNode (if you want to reach an Element even if parentElement is null): let el = textNode.parentNode?.closest?.(selector); If you want “strictest spec answer”: closest is on Element and returns an Element or null; a text node is a Node of type TEXT_NODE (3), not an Element. [1][2][3]

Citations:


🏁 Script executed:

# Let's verify the behavior with a simple test
node <<'EOF'
// Test 1: Verify that Text nodes don't have closest() method
const textNode = document.createTextNode("hello");
console.log("Text node has closest?", typeof textNode.closest);
console.log("Text node nodeType:", textNode.nodeType); // Should be 3 (TEXT_NODE)

// Test 2: Verify that Element nodes have closest() method
const el = document.createElement("div");
console.log("Element has closest?", typeof el.closest);

// Test 3: Verify instanceof check
console.log("Text node instanceof Element?", textNode instanceof Element);
console.log("Element instanceof Element?", el instanceof Element);
EOF

Repository: LeanerCloud/CUDly

Length of output: 688


🏁 Script executed:

# Check if there are other similar patterns in the file that might have the same issue
rg -n "e\.target as HTML" frontend/src/recommendations.ts

Repository: LeanerCloud/CUDly

Length of output: 112


🏁 Script executed:

# Check if there are other similar force-cast patterns with closest() calls in the entire codebase
rg -n "\.target as HTML" frontend/src/ | head -20

Repository: LeanerCloud/CUDly

Length of output: 922


🏁 Script executed:

# Let's check the context around line 332 in userList.ts to see if it has the same issue
sed -n '328,340p' frontend/src/users/userList.ts

Repository: LeanerCloud/CUDly

Length of output: 506


Guard e.target before calling closest().

Line 3288 force-casts MouseEvent.target to HTMLElement without validation, but clicks can originate from text nodes. The closest() method exists only on Element nodes—calling it on a text node throws a TypeError, breaking the row toggle on plain text clicks.

Suggested fix
-      const target = e.target as HTMLElement;
-      if (target.closest('input, button, a, label, select, [data-action]')) return;
+      const target = e.target;
+      if (target instanceof Element && target.closest('input, button, a, label, select, [data-action]')) return;
📝 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
tr.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.closest('input[type="checkbox"], button, a')) return;
const id = tr.dataset['recId'] || '';
const rec = recommendations.find((r) => r.id === id);
if (rec) openDetailDrawer(rec);
if (target.closest('input, button, a, label, select, [data-action]')) return;
tr.addEventListener('click', (e) => {
const target = e.target;
if (target instanceof Element && target.closest('input, button, a, label, select, [data-action]')) return;
🤖 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 3287 - 3289, The click handler
on tr (tr.addEventListener) unsafely casts e.target to HTMLElement and calls
closest(), which can throw for text nodes; update the handler to first check
that e.target is an Element (e.g., using "if (!(e.target instanceof Element))
return;") before assigning to target or calling target.closest(...) so only
Element nodes invoke closest and avoid TypeError when clicking plain text.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 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 1 file(s) based on 1 unresolved review comment.

Files modified:

  • frontend/src/recommendations.ts

Commit: b0b261a4491985c8149c69ba6d0f223088f0fc85

The changes have been pushed to the feat/issue-344-row-click-selection branch.

Time taken: 3m 2s

Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
cristim added a commit that referenced this pull request May 13, 2026
…358)

Four users.test.ts cases were stale on `feat/multicloud-web-frontend`
and are failing on every PR-merge ref against it (including #336, #352,
others). The tests assert the pre-#348 / pre-#347 behaviour:

1. `loadUsers › should load users and groups`
2. `loadUsers › should render groups after loading`
   `mockGroups` is missing the `allowed_accounts: []` field that the
   backend now ships on every group (multi-cloud account-scoping work).
   The actual groups from `api.listGroups` carry the field, so the
   deep-equal assertion against the fixture diverges.

3. `openCreateUserModal › should make password required`
4. `saveUser › should validate empty password for new user`
   Issue #348 ("invite users without a password and let them set it on
   first login") made password optional on create. userModals.ts now
   explicitly sets `passwordInput.required = false` (line 36) so the
   form-level HTML validation doesn't block the invite flow, and
   saveUser intentionally calls `api.createUser` with an empty password
   to trigger the backend invite-email path. Both old tests asserted
   the opposite behaviour and now fail.

Fix:
- Add `allowed_accounts: []` to the two `mockGroups` entries.
- Invert "should make password required" → "should not mark password
  as required (blank invites the user)" asserting `required === false`.
- Replace "should validate empty password" → "should allow empty
  password for new user (invite flow, issue #348)" asserting
  createUser was called with an empty password and no error toast was
  rendered.

Unblocks the frontend-build-sentinel workflow on PRs against
`feat/multicloud-web-frontend`. 1633 frontend tests pass.
@cristim cristim merged commit fe9267f into feat/multicloud-web-frontend May 13, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/all-users Affects every user priority/p2 Backlog-worthy severity/medium Moderate 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