Skip to content

refactor: Unify section/group into single Group with collapsible/bordered options#2015

Open
alex-fedotyev wants to merge 20 commits intomainfrom
feat/unified-group
Open

refactor: Unify section/group into single Group with collapsible/bordered options#2015
alex-fedotyev wants to merge 20 commits intomainfrom
feat/unified-group

Conversation

@alex-fedotyev
Copy link
Copy Markdown
Contributor

@alex-fedotyev alex-fedotyev commented Mar 30, 2026

Summary

Merges the separate "section" and "group" container types into a single DashboardContainer concept with configurable options:

  • Collapsible (optional, default true) — chevron toggle with URL-based collapse state
  • Bordered (optional, default true) — visual border around the container
  • Tabbed (optional) — add tabs to any container; tab bar appears with 2+ tabs. Active tab is per-viewer via URL state.
  • Alert indicators — red dot on tabs (multi-tab groups) and on the group header (plain/collapsed) when a tile is in AlertState.ALERT, so an alerting group is visible at a glance even when the alerting tile is below the fold

This addresses the UX concern from #1972 that "section" and "group" are near-synonyms that force users to choose between two similar concepts.

Screenshots

Dashboard with groups and tabs (expanded):

Groups with tabs

Overflow menu (Add Tab, Collapse, Border, Delete):

Overflow menu

Collapsed with pipe-separated tab names (Overview | Latency | Errors):

Collapsed

Commits (review guide)

Area What to review
Schematypes.ts new optional fields (tabs, collapsible, bordered, activeTabId, tabId); type discriminator removed
DnD infrastructure DashboardDndContext.tsx, DashboardDndComponents.tsx: sortable wrappers, drag handle
DashboardContainer DashboardContainer.tsx + GroupTabBar.tsx: unified component with tabs, collapse, borders, alerts, a11y
Hooks useDashboardContainers.tsx: container/tab CRUD. useTileSelection.ts: multi-select + Cmd+G (auto-deletes emptied source containers)
Dashboard page DBDashboardPage.tsx: wiring, alert computation, DnD, tile positioning, URL-based active-tab + collapse state
Tests DashboardContainer.test.tsx, useDashboardContainers.test.tsx, useTileSelection.test.tsx, dashboardSections.test.tsx

Key decisions

  • No type discriminator — containers are defined by properties. Extensible via additional fields later (e.g., planned variable-repeat feature could add repeat: { filterId; as: 'tabs' \| 'sections' } cleanly onto the same container).
  • Collapsed label — pipe-separated tab names (max 4 + "…"), not dot-separated, no duplicate header.
  • Alert indicators — red dot on tabs with AlertState.ALERT tiles. Also shown at the group-header level when no tab bar is rendered (plain/collapsed), so a single alerting tile in a large dashboard isn't hidden.
  • Accessibility — chevron has role/tabIndex/aria-expanded/onKeyDown. Hidden controls removed from tab order.
  • Per-viewer UI state via URL — collapse/expand and active-tab are per-viewer (URL), falling back to DB defaults. Switching tabs in your browser doesn't affect another viewer's session.
  • Tab delete confirmation — deleting a tab with tiles opens a 3-button modal: Cancel / Move Tiles to [first remaining tab] / Delete Tab & Tiles. Destructive option is the red primary.
  • Group delete confirmation — deleting a group with tiles opens a 3-button modal: Cancel / Ungroup Tiles / Delete Group & Tiles. Ungroup preserves tiles as top-level; Delete removes them. Works consistently for plain groups, legacy sections, and multi-tab groups (all tiles across all tabs are handled).

Backward compatibility

  • Dashboards without containers render as before
  • Old type: 'section' containers parse successfully (Zod strips extra field)
  • New optional fields (collapsible, bordered, tabs, activeTabId, tabId) — undefined treated as defaults
  • No Mongoose migration needed: the containers field is a schemaless Schema.Types.Array; all validation happens at the Zod layer
  • Covered by tests: useDashboardContainers.test.tsx "legacy dashboard upgrade path" and dashboardSections.test.tsx:273 "old dashboards with type field still parse"

Review feedback addressed

  • ✅ Semantic tokens everywhere (replaced raw Mantine color tokens)
  • ✅ Mantine components over raw HTML / style props
  • ✅ Component decomposition — GroupTabBar extracted from the main component; DashboardContainerRow extracted from inline render in DBDashboardPage
  • ✅ Accessibility — chevron a11y, hidden controls removed from tab order
  • ✅ URL state for per-viewer UI (activeTabId + collapse/expand) — the only non-structural setDashboard call migrated off the server
  • ✅ Renamed GroupContainerDashboardContainer to match the schema type
  • ✅ Auto-delete emptied source containers after Cmd+G regrouping
  • ✅ 3-option prompt for group delete (Cancel / Ungroup Tiles / Delete Group & Tiles)
  • ✅ Alert dot now appears on expanded plain/single-tab group headers (previously only on collapsed)
  • ✅ Fix: spurious setDashboard on every tile drag in multi-tab containers (layout handler was built with all-tab tiles but RGL only renders visible-tab tiles)
  • ✅ Take viewer to newly-created tab after Add Tab (clears stale URL override)
  • ✅ Removed redundant WHAT-style comments; kept WHY / invariant comments
  • ✅ Removed dead API surface (unused handleToggleCollapsed export from the hook)
  • ✅ Documented the : separator invariant in the activeTabs URL param

Test plan

  • 1515 app unit tests pass
  • 745 common-utils unit tests pass
  • ESLint clean across all packages (make ci-lint)
  • TypeScript clean (app, common-utils, api)
  • Changeset included
  • Verified on Vercel preview

Builds on #1972

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 30, 2026

🦋 Changeset detected

Latest commit: c01ae19

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@hyperdx/app Patch
@hyperdx/common-utils Patch
@hyperdx/api Patch
@hyperdx/otel-collector Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview, Comment Apr 21, 2026 6:34pm

Request Review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 30, 2026

PR Review

Summary: Merges section/group container types into a unified DashboardContainer with collapsible, bordered, and tabbed options. Well-structured with solid test coverage and clear backward-compat story.


  • ⚠️ DashboardContainer.tsx is 439 lines, exceeding the project's 300-line file limit (per AGENTS.md). The delete-confirmation modals and the header/chevron block are natural extraction candidates.

  • ⚠️ visibleTiles shadowed inside DashboardContainerRow (DBDashboardPage.tsx ~935–985): the outer binding (used for layoutChangeHandler) and the inner render-prop binding are computed independently from activeTabId vs currentTabId. Both should be equivalent today, but the naming collision will mislead future readers and could silently diverge. Rename the outer to layoutTiles.

  • ⚠️ handleTileSelect's shiftKey param is always true when called (useTileSelection.ts): the Tile onClick guard checks e.shiftKey before calling onSelect(chart.id, true), so the if (!shiftKey) return; branch is dead code. Either remove the guard inside the hook or propagate the actual key value so the API isn't misleading.

  • ℹ️ handleDeleteTab sets tile.tabId = targetTabId rather than delete tile.tabId when targetTabId could theoretically be undefined (one-tab edge case). In practice the UI prevents this (tab bar only shows with 2+ tabs), but delete tile.tabId would be more explicit and consistent with how containerId is cleared elsewhere in the same file.

  • ✅ Backward compat is solid — Zod's default strip mode handles legacy type: 'section' cleanly, URL-state encoding (base-36 IDs + : separator) is safe, and the "legacy upgrade path" tests cover the migration.

@alex-fedotyev alex-fedotyev changed the title refactor: Unify section/group into single collapsible Group refactor: Unify section/group into single Group with collapsible/bordered options Apr 3, 2026
@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

@claude code review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 3, 2026

Claude finished @alex-fedotyev's task in 2m 11s —— View job


Code Review

  • Gather context and understand the PR
  • Read changed files
  • Analyze code quality, bugs, and patterns
  • Post review feedback

Overall this is a clean unification with good backward-compat handling. Three issues from the previous automated review remain unaddressed, plus a new bug I found in the rename path.

Issues

1. Bug: container.title goes stale on single-tab rename

packages/app/src/components/GroupContainer.tsx:79-91handleSaveRename routes through onRenameTab(firstTab.id, trimmed) when a first tab exists, but handleRenameTab in useDashboardContainers.tsx only updates container.tabs[tabId].title — it never touches container.title. After renaming, container.title holds the old value while tabs[0].title is updated.

This is fine for the display (since headerTitle = firstTab?.title ?? container.title), but becomes visible if the first tab is later deleted (making a different tab the display tab) or if the container is ever serialized in a context that reads container.title directly. The fix is to also sync container.title inside handleRenameTab when the renamed tab is the first (or only) tab — which handleRenameContainer already does. Fix this →

2. Non-obvious toggle expression (previously flagged, still present)

packages/app/src/DBDashboardPage.tsx:1501,1514:

// current — works but confusing: undefined is treated as "not false" → sets to false
c.collapsible = c.collapsible === false ? true : false;

// clearer:
c.collapsible = !(c.collapsible ?? true);

Same pattern on bordered. Functionally correct since undefined === false is false (so the first press correctly disables), but the intent is obscured. Fix this →

3. Unconditional divider renders above lone delete item (previously flagged, still present)

packages/app/src/components/GroupContainer.tsx:202-213:

{onDelete && (
  <>
    <Menu.Divider />   {/* ← always fires when onDelete is present */}
    <Menu.Item color="red" ...>Delete Group</Menu.Item>
  </>
)}

If onDelete is passed but onAddTab, onToggleCollapsible, onToggleBordered, and onToggleDefaultCollapsed are all absent, the menu renders a bare divider at the top followed by Delete. Low impact in current usage (all props are passed from DBDashboardPage), but makes GroupContainer unsafe to reuse standalone. Fix this →

4. Missing GroupContainer tests (previously flagged, still not added)

SectionHeader.test.tsx (152 lines) was deleted but no GroupContainer.test.tsx was added. The merged component gained significant new behavior: collapsible toggle, bordered toggle, tab-delete confirmation dialog, collapsed tab summary, inline rename UX. Zero test coverage on all of this.

Minimum coverage worth adding:

  • Chevron hidden when collapsible={false}
  • Collapsed tab summary renders all tab names
  • Tab delete button calls onDeleteTab after confirmation
  • Overflow menu shows correct labels based on collapsible/bordered state

Minor observations

Keyboard accessibilityGroupContainer.tsx:137-152 — the Add Tile button (and overflow menu trigger) are hidden via opacity: 0 / pointerEvents: none but remain keyboard-focusable. Users navigating by tab key will land on invisible, unactionable buttons. Adding tabIndex={showControls ? 0 : -1} (or aria-hidden) when hidden would fix this.

Escape-to-cancel tab rename leaves stale editedTabTitleGroupContainer.tsx:332 — when Escape cancels a tab rename (setEditingTabId(null)), editedTabTitle is not reset. The next double-click re-initializes it correctly via setEditedTabTitle(tab.title), so there's no visible bug — just a dangling state value.

handleTabChange persists to the serveruseDashboardContainers.tsx:268-279 — the comment documents this intentionally, but it means the active tab is shared state across all viewers of a dashboard. Worth a UX note: if two users are looking at the same dashboard, one user switching tabs will affect the other's view on next load. Consider whether this is the intended design.


What looks good

  • Backward-compat schema (type: 'section' accepted, new code always writes 'group') — correct and clean.
  • hasTabs = tabs.length >= 2 threshold is the right place to gate the tab bar — a single tab renders as a plain group header, which matches the described UX.
  • useTileSelection correctly assigns tabId when grouping tiles via Cmd+G.
  • handleDeleteTab safely merges tiles to the first remaining tab in both the 2→1 and N→N-1 cases.
  • handleAddTab legacy path (0 existing tabs) correctly creates 2 named tabs and migrates existing tiles to Tab 1.
  • DnD sortable ID rename (section-sort-container-sort-) is safe — IDs are ephemeral/in-memory.
  • Inline tab rename with a native <input> matching tab text dimensions avoids the UI jump correctly.

@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

@claude code review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 3, 2026

Claude finished @alex-fedotyev's task in 3m 12s —— View job


Code Review

  • Gather context and understand the PR
  • Read changed files
  • Analyze code quality, bugs, and patterns
  • Post review feedback

The previous automated review flagged 4 issues. All 4 have been addressed in this revision — nice cleanup. Findings below.

Previously-flagged issues: all resolved ✅

# Issue Status
1 container.title goes stale on single-tab rename ✅ Fixed — handleRenameTab (useDashboardContainers.tsx:217-219) now syncs container.title when renaming tabs[0]
2 Non-obvious toggle expression ✅ Fixed — now !(c.collapsible ?? true) and !(c.bordered ?? true) at DBDashboardPage.tsx:1502,1518
3 Unconditional divider above Delete ✅ Fixed — divider is now correctly guarded by (onAddTab || onToggleCollapsible || onToggleBordered || onToggleDefaultCollapsed) at GroupContainer.tsx:204-207
4 Missing GroupContainer tests ✅ Added — 257-line GroupContainer.test.tsx covers chevron, borders, collapsed tab summary, overflow menu, tab bar, and tab delete with/without confirm

Remaining issues

1. Accessibility regression (still unaddressed from original automated review)

packages/app/src/components/GroupContainer.tsx:121-134 — The chevron (IconChevronRight) is a bare SVG element with only onClick. Screen readers and keyboard users cannot interact with it: no role, no tabIndex, no aria-expanded, no aria-label.

// current
<IconChevronRight
  onClick={onToggle}
 
/>

// fix: wrap or add attributes
<IconChevronRight
  role="button"
  tabIndex={0}
  aria-expanded={!isCollapsed}
  ? 'Expand group' : 'Collapse group'}
  onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onToggle()}
  onClick={onToggle}
  ...
/>

Fix this →

2. Keyboard-focusable invisible controls

GroupContainer.tsx:137-152 and 154-168 — The "Add Tile" button and overflow menu trigger hide via opacity: 0 + pointerEvents: none when showControls is false, but remain in the tab order. A keyboard user tabbing through the dashboard will silently land on and interact with invisible buttons.

// add tabIndex alongside the existing opacity/pointerEvents
style={{ opacity: showControls ? 1 : 0, pointerEvents: showControls ? 'auto' : 'none' }}
// + tabIndex={showControls ? 0 : -1}

Fix this →

3. Stale URL collapse state when re-enabling collapsible (low severity)

DBDashboardPage.tsx:1495-1510handleToggleCollapsible clears c.collapsed (DB) when disabling but does not clear the URL collapsed/expanded sets. If a container was URL-collapsed, then collapsibility is disabled and re-enabled, the container reappears URL-collapsed even though the user last saw it expanded. The isCollapsed = collapsible && collapsed guard in GroupContainer prevents any actual stuck state while collapsible is disabled, but the URL set accumulates stale entries.

Fix this →


Minor observations

Stale editedTabTitle on Escape (GroupContainer.tsx:335) — setEditingTabId(null) cancels the edit but editedTabTitle is not reset. No visible bug since the next double-click re-initializes it, but it leaves dangling state.

GroupContainer.tsx is ~480 lines — AGENTS.md recommends keeping files under 300 lines. The tab bar rendering could be extracted to a TabBar sub-component when this grows further.

Shared active tab state (useDashboardContainers.tsx:270-283) — handleTabChange persists to the server, meaning all viewers share the same active tab. The comment documents this as intentional, which is good. Worth a UX callout in docs if this ships to multi-user teams.


What looks good

  • Schema change is clean: removing type and relying on property-based behavior is the right design. The backward-compat test (type: 'section' accepted via Zod passthrough) gives confidence.
  • handleAddTab legacy path (0 existing tabs): correctly creates 2 tabs and migrates existing tiles to Tab 1 — no tiles are lost.
  • handleDeleteTab: correctly merges tiles to the first remaining tab in both 2→1 and N→N-1 cases, and the children(undefined) path in DBDashboardPage:2042-2046 correctly shows all container tiles when hasTabs = false.
  • handleRenameContainerhandleRenameTab sync: the 1-tab sync in both directions (handleRenameContainer:64-66 and handleRenameTab:217-219) is correct and symmetric.
  • Test coverage added: tab delete with confirmation, rejected confirmation, collapsed summary, chevron visibility, border toggle, and tab bar threshold — all key behaviors covered.

@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

@claude - review code

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 3, 2026

Claude finished @alex-fedotyev's task in 2m 40s —— View job


Code Review

  • Gather context and understand the PR
  • Read changed files
  • Analyze code quality, bugs, and patterns
  • Post review feedback

All issues from the previous two review rounds are resolved. Three new findings below.

Previously flagged issues: all resolved ✅

Issue Status
container.title stale on single-tab rename ✅ Fixed — handleRenameTab syncs container.title at useDashboardContainers.tsx:218-219
Non-obvious toggle expressions ✅ Fixed — !(c.collapsible ?? true) / !(c.bordered ?? true)
Unconditional divider before Delete ✅ Fixed — divider now guarded at GroupContainer.tsx:234-237
Missing GroupContainer tests ✅ Added — 329-line GroupContainer.test.tsx covering chevron, border, collapse, tabs, alert dots
Chevron not keyboard-accessible ✅ Fixed — role="button", tabIndex={0}, aria-expanded, aria-label, onKeyDown added
Invisible controls in tab order (addTile + overflow menu) ✅ Fixed — tabIndex={showControls ? 0 : -1} added to both
Stale URL collapse state on re-enable ✅ Fixed — removeFromUrlSet called for both sets in handleToggleCollapsible

New issues

1. Tab rename button (pencil icon) still keyboard-focusable when hidden

GroupContainer.tsx:402-423 — The tab-bar pencil button has opacity/pointerEvents toggled on showControls, but is missing tabIndex={showControls ? 0 : -1}. The same fix was applied to addTileButton (line 170) and the overflow ActionIcon (line 189), but this third hidden button was missed.

// current — stays in tab order when invisible
<ActionIcon
  variant="subtle"
  size="sm"
  style={{ opacity: showControls ? 1 : 0, pointerEvents: showControls ? 'auto' : 'none' }}
  ...
>

// fix — one line addition
  tabIndex={showControls ? 0 : -1}

Fix this →


2. Spurious alert dot when container has no tabs

DBDashboardPage.tsx:2015alertingTabIds.add(tile.tabId ?? firstTabId ?? '') falls back to '' when a container has no tabs array (groupTabs = [], so firstTabId = undefined). hasContainerAlert checks alertingTabIds.size > 0, so if an alerting tile is in a tab-less container, the empty string '' is added to the set and the check passes, rendering an AlertDot on a group that has no tabs to indicate.

In practice this only occurs for containers created before the tab system (legacy containers), but the empty-string sentinel is a code smell. Filtering it out explicitly is cleaner:

// current
alertingTabIds.add(tile.tabId ?? firstTabId ?? '');

// fix — only add a real id; skip if there's nothing to attribute to
const attributedTabId = tile.tabId ?? firstTabId;
if (attributedTabId) alertingTabIds.add(attributedTabId);

Fix this →


3. handleDeleteTab doesn't sync container.title for the surviving tab

useDashboardContainers.tsx:228-265 — When deleting tabs[0] and keeping tabs[1], container.title is not updated to match the surviving tab's title. headerTitle = firstTab?.title ?? container.title reads the surviving tab's title correctly for display, but container.title stays as the old deleted tab's name.

This means:

  • If the user later deletes all tabs, container.title shows the stale value.
  • Any code path that reads container.title directly (e.g. serialization, future features) will see the old name.

handleRenameTab already syncs container.title when renaming tabs[0]. The delete path should do the same for the new first tab after deletion:

// after setting c.tabs = remaining, add:
if (remaining[0]) {
  c.title = remaining[0].title;
}

Fix this →


Minor observations

pr-screenshots/ still present — PR description says "should be removed before merge." The four binary PNGs still bloat git history. Remove them and host in the PR description directly.

handleAddTab naming inconsistency — When going 1→2 tabs, the new tab is named 'New Tab' (line 169). When going 2+→N, it's Tab ${existingTabs.length + 1} (line 197). Minor UX inconsistency; a user who clicks "Add Tab" twice gets "New Tab" then "Tab 3".

Escape-to-cancel leaves stale editedTabTitle (GroupContainer.tsx:370) — setEditingTabId(null) on Escape doesn't reset editedTabTitle. No visible bug since re-opening re-initializes it via setEditedTabTitle(tab.title), but it's dangling state. Noted in previous review, still present.

GroupContainer.tsx is ~525 lines — Over the 300-line AGENTS.md recommendation. The tab bar section could be extracted to a TabBar sub-component if it grows further.


What looks good

  • Schema change is clean: type field removed from DashboardContainerSchema, Zod strips type: 'section' from old data correctly. Tests in dashboardSections.test.tsx confirm.
  • handleRenameTabhandleRenameContainer bidirectional sync is correct and symmetric.
  • handleDeleteTab tile migration (2→1 and N→N-1 cases) is correct.
  • handleAddTab legacy path (0 tabs → 2 tabs) correctly migrates existing tiles to tab1.
  • collapsed / collapsible interaction (isCollapsed = collapsible && collapsed) correctly prevents stuck-collapsed state.
  • All 7 previously flagged issues addressed cleanly.

@elizabetdev elizabetdev self-requested a review April 6, 2026 21:02
Copy link
Copy Markdown
Contributor

@elizabetdev elizabetdev left a comment

Choose a reason for hiding this comment

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

It's looking good @alex-fedotyev! From a design perspective let's try to stick to semantic tokens.

Comment thread packages/app/src/components/DashboardDndContext.tsx Outdated
Comment thread packages/app/src/components/GroupContainer.tsx Outdated
Comment thread packages/app/src/components/GroupContainer.tsx Outdated
Comment thread packages/app/src/components/GroupContainer.tsx Outdated
Comment thread packages/app/src/components/DashboardDndComponents.tsx Outdated
Replace mantine-specific CSS variables with semantic design tokens:
- var(--mantine-color-body) → var(--color-bg-body)
- var(--mantine-color-default-border) → var(--color-border)
- var(--mantine-color-red-filled) → var(--color-bg-danger)

Addresses review comments from elizabetdev on PR #2015.
- --mantine-color-dimmed → --color-text-muted (chevron, drag handle)
- --mantine-color-default-border → --color-border (plain header)
- Remove dashed border from empty container placeholder (avoids
  border-inside-border per review feedback)
Comment thread packages/app/src/components/DashboardDndComponents.tsx Outdated
Comment thread packages/app/src/components/DashboardDndComponents.tsx Outdated
Comment thread packages/app/src/components/GroupContainer.tsx Outdated
Comment thread packages/app/src/components/GroupContainer.tsx Outdated
Comment thread packages/app/src/components/GroupContainer.tsx Outdated
Comment thread packages/app/src/DBDashboardPage.tsx Outdated
handleRenameTab,
handleDeleteTab,
handleTabChange,
} = useDashboardContainers({ dashboard, setDashboard, confirm });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I just realized everywhere we see setDashboard, that's actually a db call. I don't think we should be doing this everywhere, seems like we should be modifying some local state and then updating after clicking "save" or something

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged — this is a significant design change (local state + save button) that goes beyond this PR's scope. Worth tracking as a follow-up. The immediate DB writes match the existing dashboard pattern elsewhere in the codebase.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point. I classified all 15 setDashboard call sites and found one that was genuinely UI-ephemeral: activeTabId (which tab this viewer is looking at). Moved it to URL state in dc153b9, matching the existing per-viewer pattern already in place for collapse state.

  • URL param activeTabs stores containerId:tabId entries per viewer
  • container.activeTabId remains as the DB-stored default for new viewers (and gets updated when tabs are added/deleted so the new/first tab is selected by default)
  • Resolution order: URL override → DB default → first tab

The other setDashboard calls are structural (tile/container/tab CRUD, filters, layout, alerts) and should persist immediately — they match the existing pattern across DBDashboardPage (e.g. RGL layout changes, filter edits, tile saves have always been immediate writes). Moving those to a local-state + save-button flow is a much larger change (navigation warnings, filter/query coupling since filters drive live queries, RGL drag batching, multi-user concurrency). Happy to open a follow-up ticket if you want to pursue that separately — this PR isn't the right scope.

Comment thread packages/app/src/DBDashboardPage.tsx Outdated
containerTitle={container.title}
>
{(dragHandleProps: DragHandleProps) => (
<GroupContainer
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we have a better name? It sounds like a generic framework component, like how mantine has both Group and Container

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Open to suggestions on naming. The render in DBDashboardPage now uses DashboardGroupItem as the wrapper component. GroupContainer is the reusable UI component. Happy to rename if there's a preferred convention.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed GroupContainer → DashboardContainer in 9c39efa. DashboardContainer matches the schema type name in common-utils/types.ts, so the component name aligns with the data model. The schema type is aliased as DashboardContainerSchema locally where both are needed in the same file (component file + DBDashboardPage). The wrapper previously named DashboardGroupItem is now DashboardContainerRow for consistency.

Note: we discussed the future variable-repeat feature (repeat as tabs vs. sections linked to a filter) and this name works cleanly for both static and derived containers — Group / Tabs / Sections are rendering modes, DashboardContainer is the thing that has them.

Comment thread packages/app/src/DBDashboardPage.tsx Outdated
Comment thread packages/app/src/DBDashboardPage.tsx Outdated
Comment thread packages/app/src/DBDashboardPage.tsx Outdated
…naming

- Replace raw divs/spans with Mantine Box, Flex, Center, TextInput components
- Extract GroupTabBar sub-component from GroupContainer (~180 lines)
- Extract DashboardGroupItem to eliminate double-nested inline rendering
- Pre-compute alertingTabIdsByContainer as useMemo outside render loop
- Rename state: editing→isRenamingGroup, editedTitle→groupRenameValue, etc.
- Merge 1-tab and 2+-tab cases in handleAddTab
- Remove default params from calculateNextTilePosition (callers pass explicit)
- Use semantic token --color-outline-focus for tile selection outline
- Remove unnecessary hasContainers ternary in render
- Add comment explaining tab/title sync semantics
Copy link
Copy Markdown
Contributor

@knudtty knudtty left a comment

Choose a reason for hiding this comment

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

Just this and fixing the delete behavior

Comment thread packages/app/src/DBDashboardPage.tsx Outdated
Comment on lines +872 to +873
// Extracted component for rendering a single dashboard group/container.
// Eliminates the double-nested inline function rendering pattern.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Comments like these aren't necessary

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Audited all PR-added comments and removed the WHAT-style ones in 08593ae (DashboardGroupItem banner, 'Update container assignment', section banners like '--- Tab management ---', 'Legacy container with no tabs: create 2 tabs', 'Move tiles to first remaining tab', etc.). Kept comments that explain WHY / non-obvious invariants (tab/title sync semantics, URL-precedence-over-DB rationale, default tile size reasoning).

Comment on lines +781 to +788
// Whether the group can be collapsed (default true)
collapsible: z.boolean().optional(),
// Whether to show a border around the group (default true)
bordered: z.boolean().optional(),
// Optional tabs: 2+ entries → tab bar renders, 0-1 → plain group header.
// Tiles reference a specific tab via tabId.
tabs: z.array(DashboardContainerTabSchema).optional(),
activeTabId: z.string().optional(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Has this been tested in a non-preview environment? I worry if there are migrations needed and if the mongoose model needs to be updated.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No migration needed — the Mongoose model stores containers as Schema.Types.Array (schemaless), so MongoDB accepts any shape. The only validation is the Zod schema on the frontend, which handles the upgrade cleanly:

  • Old type: 'section' field → silently stripped by z.object() (default behavior)
  • New fields (tabs, collapsible, bordered, activeTabId, tabId) → all .optional(), default to undefined

Added functional tests that exercise the upgrade path — start with a realistic old-format dashboard (no tabs, no tabId, no collapsible/bordered) and run all hook operations on it:
useDashboardContainers.test.tsx — "legacy dashboard upgrade path"

Existing Zod-level test also covers backward compat:
dashboardSections.test.tsx:273 — "old dashboards with type field still parse"

Addresses PR feedback: tab deletion now shows a modal with two options
instead of silently merging tiles. Also removes unnecessary comment and
adds hook-level tests covering delete/move actions and the legacy
dashboard upgrade path.
When selecting tiles into a new group empties a source container, the now-empty container is removed. Unrelated empty containers are preserved.

Addresses review feedback on useTileSelection.ts:53.
Per review feedback, GroupContainer is too generic (resembles Mantine's Group/Container).

DashboardContainer matches the schema type in common-utils/types.ts, making the
component name align with the data model. The schema type is aliased locally
as DashboardContainerSchema where both are needed in the same file.

The wrapper component previously named DashboardGroupItem is renamed to
DashboardContainerRow for consistency.
Tab selection is now per-viewer rather than persisted to the dashboard.
Matches the existing URL-based pattern for per-viewer collapse state.

- Adds `activeTabs` URL param (`containerId:tabId,...` entries)
- Removes `handleTabChange` from useDashboardContainers (DB write); the
  page-level handler now updates URL state only
- Resolution order: URL override → container.activeTabId (DB default) → first tab
- Stale URL entries are safely ignored when a tabId no longer exists

Addresses review feedback on DBDashboardPage.tsx:1604 ("setDashboard is a
db call") for the one call site that was genuinely UI-ephemeral.
…fallback

After the URL migration for activeTabId, clicking 'Add Tab' could leave
the local viewer on their old URL-selected tab because the URL override
wins over the new DB default. Now we clear the URL entry for that
container on Add Tab so the viewer is taken to the newly created tab.

Also hardened getActiveTabId to validate DB-stored activeTabId against
the actual tabs array before using it (pre-existing issue carried over
from the old resolver).
Matches the tab-delete pattern: deleting a group now opens a modal letting
the user choose whether to preserve tiles (ungroup to top level) or delete
them along with the group.

Uniform across plain groups, legacy sections, and multi-tab groups — the
filter operates on container.id and handles all tiles regardless of their
tab assignment.

- Ungroup Tiles (primary, only offered when tileCount > 0): strips
  containerId + tabId, shifts tiles below any existing ungrouped tiles
- Delete Group & Tiles (danger): removes tiles belonging to this container
- Cancel: no-op

Removed the `confirm` prop from useDashboardContainers since the hook no
longer prompts; callers render their own modal.
@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

@claude - review code

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

Claude finished @alex-fedotyev's task in 3m 43s —— View job


Code Review

  • Gather context and understand the PR
  • Read changed files
  • Check if previously flagged issues were addressed
  • Analyze code quality, bugs, and patterns
  • Post review feedback

All 3 issues from the previous review round are resolved. Two bugs from rounds 1–2 remain unaddressed, plus one new finding.

Previously flagged issues: all resolved ✅

Issue Status
Tab rename pencil button keyboard-focusable when hidden ✅ Fixed — tabIndex={showControls ? 0 : -1} at GroupTabBar.tsx:171
Spurious alert dot for tab-less containers (empty-string sentinel) ✅ Fixed — const attributedTabId = tile.tabId ?? firstTabId; if (attributedTabId) alerting.add(attributedTabId) at DBDashboardPage.tsx:1750-1751
handleDeleteTab doesn't sync container.title for surviving tab ✅ Fixed — if (firstTab) c.title = firstTab.title at useDashboardContainers.tsx:218

Remaining issues

1. Spurious setDashboard on every tile drag in multi-tab containers (round 1, still unaddressed)

DBDashboardPage.tsx:1774-1781containerLayoutChangeHandlers builds one handler per container using makeOnLayoutChange(tiles) where tiles is all tiles in the container (all tabs). But in DashboardContainerRow:989-997, that handler is passed to a ReactGridLayout that only renders visibleTiles (the active tab's tiles).

When RGL fires onLayoutChange, newLayout has visibleTiles.length entries, but currentLayout in the handler has tiles.length (all tabs). For a container with N tabs and tiles spread across them, newLayout.length !== currentLayout.lengthhasDiff = truesetDashboard is called on every drag event, even when nothing actually changed.

// current — handler captures all container tiles
const containerLayoutChangeHandlers = useMemo(() => {
  const map = new Map<string, (newLayout: RGL.Layout[]) => void>();
  for (const c of containers) {
    const tiles = tilesByContainerId.get(c.id) ?? [];       // ← all tabs
    map.set(c.id, makeOnLayoutChange(tiles));
  }
  return map;
}, [containers, tilesByContainerId, makeOnLayoutChange]);

Fix: create per-tab handlers so gridTiles matches exactly what RGL renders. The map key can be containerId, but the handler must be built with visibleTiles at the point where the tab is known (in DashboardContainerRow), or the map can be keyed by containerId:activeTabId.

Fix this →


2. Colon separator in activeTabs URL state is undocumented (round 1, still present)

DBDashboardPage.tsx:1314,1349-1352 — Active tab URL entries are stored as "containerId:tabId" and parsed with entry.split(':'). The startsWith(${containerId}:) filter assumes IDs never contain :. Current makeId() uses base-36 (safe), but there is no guard or documentation of this invariant.

// parse
const [containerId, tabId] = entry.split(':');   // ← silent data loss if ID contains ':'

// filter
filtered.filter(entry => !entry.startsWith(`${containerId}:`))

If a future ID source (import, legacy data, external integration) generates IDs with :, tab state silently corrupts. Using a separator that's explicitly outside the ID alphabet (e.g. ~, which base-36 cannot produce) or encoding as JSON would make this safe. At minimum, add a comment documenting that makeId() is base-36 and thus cannot produce :.

Fix this →


3. New: _handleToggleCollapsedDB unused (rounds 1–2, still exposed)

DBDashboardPage.tsx:1684handleToggleCollapsed is destructured from useDashboardContainers with the discard alias _handleToggleCollapsedDB and never called anywhere. The DB-collapse toggle is handled by handleToggleDefaultCollapsed instead. The hook exposes dead API surface.

Either remove handleToggleCollapsed from the hook's return value, or document why it's retained (e.g. reserved for a future "reset to default" operation). As-is, a future developer may call it expecting the behavior of handleToggleDefaultCollapsed and get confused.

Fix this →


What looks good

  • All 3 issues from the previous round addressed cleanly.
  • Alert computation (alertingTabIdsByContainer) correctly guards against missing firstTabId — no more empty-string sentinel.
  • handleDeleteTab now correctly syncs container.title to the surviving first tab's title.
  • Tab rename pencil in GroupTabBar now has proper tabIndex handling, matching the pattern applied to addTileButton and the overflow menu.
  • URL state resolution order (urlTabId → DB activeTabId → tabs[0]) in getActiveTabId is clean and correctly validates that the URL tab still exists in the container (tabs.some(t => t.id === urlTabId)).
  • DashboardDndContext.tsx correctly uses semantic color tokens (var(--color-bg-body), var(--color-border)) in the drag overlay.
  • calculateNextTilePosition correctly uses a y <= rowY && y + h > rowY predicate to find all tiles intersecting a row — more correct than a simple y === rowY check.
  • handleDeleteTab tile merge path (action 'move') correctly targets remaining[0]?.id, which is safe when there's exactly one remaining tab.
  • Schema backward compat: type: 'section' in old containers parses cleanly through Zod's default strip behavior. Test at dashboardSections.test.tsx:273 confirms.

1. Spurious setDashboard on every tile drag in multi-tab containers
   (DBDashboardPage.tsx). The layout-change handler was built with all
   container tiles across every tab, but RGL only renders visible-tab
   tiles. newLayout.length != currentLayout.length was always true for
   multi-tab containers, triggering a DB write on every drag event even
   when nothing had moved. Fix: build the handler from visibleTiles so
   both layouts align.

2. Remove unused handleToggleCollapsed from useDashboardContainers hook
   (plus its test). The hook exposed a toggle-DB-collapsed handler that
   was discarded at the only call site as _handleToggleCollapsedDB —
   the page-level handleToggleDefaultCollapsed covers the same DB
   toggle. Dead API surface, now removed.

3. Document the ':' separator invariant in the activeTabs URL param.
   Safe today because makeId() is base-36, but comment flags the
   dependency in case the ID source ever changes.
Previously the container-level alert dot was guarded by `isCollapsed`,
which meant plain (single-tab or legacy) containers in their expanded
state had no group-header indicator — users had to scan tiles to spot
alerts, which fails when tiles are below the fold.

Now the dot shows whenever the plain-header render path is taken and
the container has alerting tiles (both collapsed and expanded). The
multi-tab expanded path is unaffected since it renders the tab bar
(with per-tab dots) instead of the plain header.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/tier-4 Critical — deep review + domain expert sign-off

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants