Skip to content

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

Draft
alex-fedotyev wants to merge 6 commits intofeat/dashboard-dnd-organizationfrom
feat/unified-group
Draft

refactor: Unify section/group into single Group with collapsible/bordered options#2015
alex-fedotyev wants to merge 6 commits intofeat/dashboard-dnd-organizationfrom
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 Group concept with configurable options:

  • Collapsible (optional, default true) — chevron toggle with URL-based collapse state
  • Bordered (optional, default true) — visual border around the group
  • Tabbed (optional) — add tabs to any group; tab bar appears with 2+ tabs
  • Alert indicators — red dot on tabs/groups containing tiles with active alerts

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

Group overflow menu (Add Tab, Collapse, Border, Delete):

Overflow menu

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

Collapsed group

Commits (review guide)

# Commit What to review
1 Schema — extend DashboardContainer types.ts: new optional fields (tabs, collapsible, bordered), type field removal
2 DnD infrastructure DashboardDndContext.tsx, DashboardDndComponents.tsx: sortable wrappers, drag handle
3 GroupContainer (replaces SectionHeader) GroupContainer.tsx: unified component with tabs, collapse, borders, alerts, a11y
4 Hooks useDashboardContainers.tsx: container/tab CRUD. useTileSelection.ts: multi-select + Cmd+G
5 Dashboard page integration DBDashboardPage.tsx: wiring, alert computation, DnD, tile positioning
6 Tests GroupContainer.test.tsx (18 tests), dashboardSections.test.tsx (56 tests)

Key decisions

  • No type discriminator — containers defined by properties. Extensible via discriminated union later.
  • Collapsed label — pipe-separated tab names (max 4 + "…"), not dot-separated, no duplicate header.
  • Alert indicators — red dot on tabs with AlertState.ALERT tiles. Shows on both expanded tab bar and collapsed header.
  • Accessibility — chevron has role/tabIndex/aria-expanded/onKeyDown. Hidden controls removed from tab order.

Backward compatibility

  • Dashboards without containers render as before
  • Old type: 'section' containers parse successfully (Zod strips extra field)
  • New collapsible/bordered fields are optional — undefined treated as true

Builds on #1972

Test plan

  • 1203 app unit tests pass (18 GroupContainer + 56 dashboard container tests)
  • ESLint clean
  • TypeScript clean (common-utils and app)
  • Changeset included
  • Verified on Vercel preview

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 30, 2026

🦋 Changeset detected

Latest commit: d3cf78a

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 3, 2026 10:19pm

Request Review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 30, 2026

PR Review

  • pr-screenshots/ binary PNGs committed to repo root → These bloat git history permanently; upload screenshots to the GitHub PR description/comments instead, then remove before merge

  • ⚠️ handleTabChange persists activeTabId to server (all viewers affected) → Tab switching is shared state across all users of a dashboard. This extends the existing activeTabId behavior from old groups to all containers. The comment in useDashboardContainers.tsx:272 acknowledges this, but it may surprise users expecting per-viewer tab state. Consider noting this explicitly in the PR description or filing a follow-up.

  • ✅ Backward compat looks solid: Zod strips the old type field on parse, collapsible/bordered default to true when undefined, collapsed is preserved, and the "section" move-target behavior is unchanged (sections with no tabs were never in move targets before either).

  • ✅ Tab/container title sync is correct: handleRenameTab syncs container.title when renaming tab[0], and handleRenameContainer syncs tabs[0].title when 1 tab exists.

@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.

Add optional fields to DashboardContainerSchema:
- tabs: array of {id, title} for tabbed groups (tab bar at 2+)
- activeTabId: persisted active tab selection
- collapsible: whether the group can be collapsed (default true)
- bordered: whether to show a border (default true)

Remove the dead `type` discriminator field ('section' | 'group') —
containers are now defined by their properties. Old dashboards with
`type: 'section'` still parse (Zod strips unknown keys). The field
can be re-added as a discriminated union if semantically different
container kinds are ever needed.

Also add `tabId` to TileSchema so tiles can reference a specific tab.
- DashboardDndContext: wraps dashboard in DndContext + SortableContext
  for container drag-and-drop reordering
- DashboardDndComponents: SortableContainerWrapper (sortable item with
  drag handle props), EmptyContainerPlaceholder (shown when a container
  or tab has no tiles, with an Add button)
- DragHandleProps type exported for GroupContainer integration
Unified component for all dashboard container types. Features:
- Collapse chevron with keyboard/screen reader support (role, tabIndex,
  aria-expanded, aria-label, Enter/Space handler)
- Optional border toggle
- Tab bar (appears at 2+ tabs) with inline rename, delete confirmation
- Collapsed state shows pipe-separated tab names (max 4, then "...")
- Alert indicators: red dot on tabs/header for active alerts
- Overflow menu: Add Tab, Collapse by Default, Disable/Enable Collapse,
  Hide/Show Border, Delete Group (divider guarded)
- Hidden controls removed from keyboard tab order (tabIndex toggle)
- Fixed header height prevents UI jump on collapse/expand

Deletes SectionHeader.tsx and its tests — all functionality merged into
GroupContainer.
useDashboardContainers (301 lines): Container CRUD + tab lifecycle
- Add/rename/delete containers
- Toggle collapsed, collapsible, bordered
- Add/rename/delete tabs with tile migration
- Tab change persistence
- Container.title synced with tabs[0] on rename and delete

useTileSelection (74 lines): Multi-select + Cmd+G grouping
- Shift+click tile selection
- Cmd+G groups selected tiles into a new container
- Assigns tabId to newly grouped tiles
Wire GroupContainer, useDashboardContainers, useTileSelection, and DnD
context into DBDashboardPage:
- Add @dnd-kit dependencies (core, sortable, utilities)
- Single "New Group" in Add menu (replaces Section + Group)
- Container rendering with collapsible/bordered/tabbed support
- Tile move dropdown with IconCornerDownRight and tab targets
- URL-based collapse state (cleared on collapsible toggle)
- Alert indicator computation: alertingTabIds per container from tile
  alert state, passed to GroupContainer
- Auto-expand on collapsible disable (prevents stuck-collapsed)
- Tile positioning updated for container-aware layout
GroupContainer.test.tsx (329 lines, 18 tests):
- Collapsible: chevron show/hide, children toggle, onToggle callback
- Bordered: border style present/absent
- Collapsed tab summary: pipe-separated names, no summary when expanded
  or single-tab
- Tab bar: renders at 2+ tabs, plain header at 1 tab
- Tab delete: confirmation flow, rejected confirmation
- Alert indicators: dot on collapsed header, per-tab dot in expanded bar

dashboardSections.test.tsx (556 lines, 56 tests):
- Schema validation: containers without type field, backward compat
  with extra fields, tabs, collapsible/bordered options
- Tile grouping logic, tab filtering, container authoring operations
- Group tab operations: creation, add/delete tabs, rename sync
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant