Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/unified-group-containers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hyperdx/app": patch
"@hyperdx/common-utils": patch
---

refactor: Unify section/group into single Group with collapsible/bordered options
100 changes: 100 additions & 0 deletions packages/app/src/components/DashboardDndComponents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Box, Button } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';

import { type DragData, type DragHandleProps } from './DashboardDndContext';

// --- Empty container placeholder ---
// Visual placeholder for empty groups/tabs with optional add-tile click.

export function EmptyContainerPlaceholder({
containerId,
children,
isEmpty,
onAddTile,
}: {
containerId: string;
children?: React.ReactNode;
isEmpty?: boolean;
onAddTile?: () => void;
}) {
return (
<div
data-testid={`container-placeholder-${containerId}`}
style={{
minHeight: isEmpty ? 80 : undefined,
borderRadius: 4,
border: isEmpty
? '2px dashed var(--mantine-color-default-border)'
: undefined,
Comment thread
alex-fedotyev marked this conversation as resolved.
Outdated
position: 'relative',
}}
>
Comment thread
alex-fedotyev marked this conversation as resolved.
Outdated
{isEmpty && (
<Box
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 16px',
}}
Comment thread
alex-fedotyev marked this conversation as resolved.
Outdated
>
<Button
variant="secondary"
fw={400}
w="100%"
leftSection={<IconPlus size={16} />}
onClick={onAddTile}
>
Add
</Button>
</Box>
)}
{children}
</div>
);
}

// --- Sortable container wrapper (for container reordering) ---

export function SortableContainerWrapper({
containerId,
containerTitle,
children,
}: {
containerId: string;
containerTitle: string;
children: (dragHandleProps: DragHandleProps) => React.ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: `container-sort-${containerId}`,
data: {
type: 'container',
containerId,
containerTitle,
} satisfies DragData,
});

const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};

return (
<div ref={setNodeRef} style={style}>
{children({ ...attributes, ...listeners })}
</div>
);
}
116 changes: 116 additions & 0 deletions packages/app/src/components/DashboardDndContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { DashboardContainer } from '@hyperdx/common-utils/dist/types';
import { Box, Text } from '@mantine/core';

// --- Types ---

export type DragHandleProps = React.HTMLAttributes<HTMLElement>;

export type DragData = {
type: 'container';
containerId: string;
containerTitle: string;
};

type Props = {
children: React.ReactNode;
containers: DashboardContainer[];
onReorderContainers: (fromIndex: number, toIndex: number) => void;
};

// --- Provider (container reorder only) ---

export function DashboardDndProvider({
children,
containers,
onReorderContainers,
}: Props) {
const [activeDrag, setActiveDrag] = useState<DragData | null>(null);

const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
});
const touchSensor = useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 5 },
});
const sensors = useSensors(mouseSensor, touchSensor);

const containerSortableIds = useMemo(
() => containers.map(c => `container-sort-${c.id}`),
[containers],
);

const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveDrag((event.active.data.current as DragData) ?? null);
}, []);

const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveDrag(null);
if (!over) return;

const activeData = active.data.current as DragData | undefined;
if (!activeData) return;

// Container reorder via sortable
const overData = over.data.current as DragData | undefined;
if (
overData?.type === 'container' &&
activeData.containerId !== overData.containerId
) {
const from = containers.findIndex(c => c.id === activeData.containerId);
const to = containers.findIndex(c => c.id === overData.containerId);
if (from !== -1 && to !== -1) onReorderContainers(from, to);
}
},
[containers, onReorderContainers],
);

return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={containerSortableIds}
strategy={verticalListSortingStrategy}
>
{children}
</SortableContext>
<DragOverlay dropAnimation={null}>
{activeDrag && (
<Box
px="sm"
py={4}
style={{
background: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-default-border)',
Comment thread
alex-fedotyev marked this conversation as resolved.
Outdated
borderRadius: 4,
opacity: 0.85,
}}
>
<Text size="sm" fw={500}>
{activeDrag.containerTitle}
</Text>
</Box>
)}
</DragOverlay>
</DndContext>
);
}
16 changes: 15 additions & 1 deletion packages/common-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,8 @@ export const TileSchema = z.object({
h: z.number(),
config: SavedChartConfigSchema,
containerId: z.string().optional(),
// For tiles inside a tab container: which tab this tile belongs to
tabId: z.string().optional(),
});

export const TileTemplateSchema = TileSchema.extend({
Expand All @@ -767,11 +769,23 @@ export const TileTemplateSchema = TileSchema.extend({

export type Tile = z.infer<typeof TileSchema>;

export const DashboardContainerTabSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
});

export const DashboardContainerSchema = z.object({
id: z.string().min(1),
type: z.enum(['section']),
title: z.string().min(1),
collapsed: z.boolean(),
// 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(),
Comment on lines +781 to +788
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"

});

export type DashboardContainer = z.infer<typeof DashboardContainerSchema>;
Expand Down