Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Binary file added docs/assets/collapse-url-state-demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
166 changes: 122 additions & 44 deletions packages/app/src/DBDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { useRouter } from 'next/router';
import { formatRelative } from 'date-fns';
import produce from 'immer';
import { pick } from 'lodash';
import { parseAsString, useQueryState } from 'nuqs';
import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs';
import { ErrorBoundary } from 'react-error-boundary';
import RGL, { WidthProvider } from 'react-grid-layout';
import { useForm, useWatch } from 'react-hook-form';
Expand Down Expand Up @@ -1101,38 +1101,42 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {

const [editedTile, setEditedTile] = useState<undefined | Tile>();

const onAddTile = (containerId?: string) => {
// Auto-expand collapsed section so the new tile is visible
if (containerId && dashboard) {
const section = dashboard.containers?.find(s => s.id === containerId);
if (section?.collapsed) {
setDashboard(
produce(dashboard, draft => {
const s = draft.containers?.find(c => c.id === containerId);
if (s) s.collapsed = false;
}),
);
}
}
setEditedTile({
id: makeId(),
x: 0,
y: 0,
w: 8,
h: 10,
config: {
...DEFAULT_CHART_CONFIG,
source: sources?.[0]?.id ?? '',
},
...(containerId ? { containerId } : {}),
});
};

const sections = useMemo(
() => dashboard?.containers ?? [],
[dashboard?.containers],
);
const hasSections = sections.length > 0;

// URL-based collapse state: tracks which sections the current viewer has
// explicitly collapsed/expanded. Falls back to the DB-stored default.
const [urlCollapsedIds, setUrlCollapsedIds] = useQueryState(
'collapsed',
parseAsArrayOf(parseAsString).withOptions({ history: 'replace' }),
);
const [urlExpandedIds, setUrlExpandedIds] = useQueryState(
'expanded',
parseAsArrayOf(parseAsString).withOptions({ history: 'replace' }),
);

const collapsedIdSet = useMemo(
() => new Set(urlCollapsedIds ?? []),
[urlCollapsedIds],
);
const expandedIdSet = useMemo(
() => new Set(urlExpandedIds ?? []),
[urlExpandedIds],
);

const isSectionCollapsed = useCallback(
(section: DashboardContainer): boolean => {
// URL state takes precedence over DB default
if (collapsedIdSet.has(section.id)) return true;
if (expandedIdSet.has(section.id)) return false;
return section.collapsed ?? false;
},
[collapsedIdSet, expandedIdSet],
);

const allTiles = useMemo(() => dashboard?.tiles ?? [], [dashboard?.tiles]);

const handleMoveTileToSection = useCallback(
Expand Down Expand Up @@ -1287,10 +1291,56 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
[dashboard, setDashboard],
);

// Intentionally persists collapsed state to the server via setDashboard
// (same pattern as tile drag/resize). This matches Grafana and Kibana
// behavior where collapsed state is saved with the dashboard for all viewers.
// Helpers for updating URL-based collapse sets via immer.
const addToUrlSet = useCallback(
(setter: typeof setUrlCollapsedIds, containerId: string) => {
setter(prev =>
produce(prev ?? [], draft => {
if (!draft.includes(containerId)) draft.push(containerId);
}),
);
},
[],
);

const removeFromUrlSet = useCallback(
(setter: typeof setUrlCollapsedIds, containerId: string) => {
setter(prev => {
const next = (prev ?? []).filter(id => id !== containerId);
return next.length > 0 ? next : null;
});
},
[],
);

// Toggle collapse in URL state only (per-viewer, shareable via link).
// Does NOT persist to DB — the DB `collapsed` field is the default.
const handleToggleSection = useCallback(
(containerId: string) => {
const section = dashboard?.containers?.find(s => s.id === containerId);
const currentlyCollapsed = section ? isSectionCollapsed(section) : false;

if (currentlyCollapsed) {
addToUrlSet(setUrlExpandedIds, containerId);
removeFromUrlSet(setUrlCollapsedIds, containerId);
} else {
addToUrlSet(setUrlCollapsedIds, containerId);
removeFromUrlSet(setUrlExpandedIds, containerId);
}
},
[
dashboard?.containers,
isSectionCollapsed,
addToUrlSet,
removeFromUrlSet,
setUrlCollapsedIds,
setUrlExpandedIds,
],
);

// Toggle the DB-stored default collapsed state (menu action).
// This changes what all viewers see by default when opening the dashboard.
const handleToggleDefaultCollapsed = useCallback(
(containerId: string) => {
if (!dashboard) return;
setDashboard(
Expand All @@ -1303,6 +1353,28 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
[dashboard, setDashboard],
);

const onAddTile = (containerId?: string) => {
// Auto-expand collapsed section via URL state so the new tile is visible
if (containerId) {
const section = dashboard?.containers?.find(s => s.id === containerId);
if (section && isSectionCollapsed(section)) {
handleToggleSection(containerId);
}
}
setEditedTile({
id: makeId(),
x: 0,
y: 0,
w: 8,
h: 10,
config: {
...DEFAULT_CHART_CONFIG,
source: sources?.[0]?.id ?? '',
},
...(containerId ? { containerId } : {}),
});
};

const handleAddSection = useCallback(() => {
if (!dashboard) return;
setDashboard(
Expand Down Expand Up @@ -1752,26 +1824,32 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
<SectionHeader
section={section}
tileCount={sectionTiles.length}
collapsed={isSectionCollapsed(section)}
defaultCollapsed={section.collapsed ?? false}
onToggle={() => handleToggleSection(section.id)}
onToggleDefaultCollapsed={() =>
handleToggleDefaultCollapsed(section.id)
}
onRename={newTitle =>
handleRenameSection(section.id, newTitle)
}
onDelete={() => handleDeleteSection(section.id)}
onAddTile={() => onAddTile(section.id)}
/>
{!section.collapsed && sectionTiles.length > 0 && (
<ReactGridLayout
layout={sectionTiles.map(tileToLayoutItem)}
containerPadding={[0, 0]}
onLayoutChange={sectionLayoutChangeHandlers.get(
section.id,
)}
cols={24}
rowHeight={32}
>
{sectionTiles.map(renderTileComponent)}
</ReactGridLayout>
)}
{!isSectionCollapsed(section) &&
sectionTiles.length > 0 && (
<ReactGridLayout
layout={sectionTiles.map(tileToLayoutItem)}
containerPadding={[0, 0]}
onLayoutChange={sectionLayoutChangeHandlers.get(
section.id,
)}
cols={24}
rowHeight={32}
>
{sectionTiles.map(renderTileComponent)}
</ReactGridLayout>
)}
</div>
);
})}
Expand Down
45 changes: 29 additions & 16 deletions packages/app/src/components/SectionHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,24 @@ import {
export default function SectionHeader({
section,
tileCount,
collapsed,
defaultCollapsed,
onToggle,
onToggleDefaultCollapsed,
onRename,
onDelete,
onAddTile,
}: {
section: DashboardContainer;
tileCount: number;
/** Effective collapsed state (URL state ?? DB default). */
collapsed: boolean;
/** The DB-stored default collapsed state. */
defaultCollapsed: boolean;
/** Toggle collapse in URL state (chevron click). */
onToggle: () => void;
/** Toggle the DB-stored default collapsed state (menu action). */
onToggleDefaultCollapsed?: () => void;
onRename?: (newTitle: string) => void;
onDelete?: () => void;
onAddTile?: () => void;
Expand All @@ -31,7 +41,7 @@ export default function SectionHeader({
const [menuOpen, setMenuOpen] = useState(false);

const showControls = hovered || menuOpen;
const hasMenuControls = onDelete != null;
const hasMenuControls = onDelete != null || onToggleDefaultCollapsed != null;

const handleSaveRename = () => {
const trimmed = editedTitle.trim();
Expand Down Expand Up @@ -81,13 +91,13 @@ export default function SectionHeader({
}
role="button"
tabIndex={editing ? undefined : 0}
aria-expanded={!section.collapsed}
aria-expanded={!collapsed}
aria-label={`Toggle ${section.title} section`}
>
<IconChevronRight
size={16}
style={{
transform: section.collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
transform: collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
transition: 'transform 150ms ease',
flexShrink: 0,
color: 'var(--mantine-color-dimmed)',
Expand Down Expand Up @@ -127,7 +137,7 @@ export default function SectionHeader({
>
{section.title}
</Text>
{section.collapsed && tileCount > 0 && (
{collapsed && tileCount > 0 && (
<Text size="xs" c="dimmed">
({tileCount} {tileCount === 1 ? 'tile' : 'tiles'})
</Text>
Expand Down Expand Up @@ -170,18 +180,21 @@ export default function SectionHeader({
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={
section.collapsed ? (
<IconEye size={14} />
) : (
<IconEyeOff size={14} />
)
}
onClick={onToggle}
>
{section.collapsed ? 'Expand by Default' : 'Collapse by Default'}
</Menu.Item>
{onToggleDefaultCollapsed && (
<Menu.Item
leftSection={
defaultCollapsed ? (
<IconEye size={14} />
) : (
<IconEyeOff size={14} />
)
}
onClick={onToggleDefaultCollapsed}
data-testid={`section-toggle-default-${section.id}`}
>
{defaultCollapsed ? 'Expand by Default' : 'Collapse by Default'}
</Menu.Item>
)}
{onDelete && (
<>
<Menu.Divider />
Expand Down
Loading
Loading