Skip to content
Merged
2 changes: 1 addition & 1 deletion packages/api/src/models/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default mongoose.model<IDashboard>(
savedQuery: { type: String, required: false },
savedQueryLanguage: { type: String, required: false },
savedFilterValues: { type: mongoose.Schema.Types.Array, required: false },
sections: { type: mongoose.Schema.Types.Array, required: false },
containers: { type: mongoose.Schema.Types.Array, required: false },
},
{
timestamps: true,
Expand Down
177 changes: 163 additions & 14 deletions packages/app/src/DBDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import {
AlertState,
ChartConfigWithDateRange,
DashboardContainer,
DashboardFilter,
DisplayType,
Filter,
Expand Down Expand Up @@ -64,6 +65,7 @@ import {
IconDotsVertical,
IconDownload,
IconFilterEdit,
IconLayoutList,
IconPencil,
IconPlayerPlay,
IconRefresh,
Expand Down Expand Up @@ -152,6 +154,8 @@ const Tile = forwardRef(
onEditClick,
onDeleteClick,
onUpdateChart,
onMoveToSection,
containers: availableSections,
granularity,
onTimeRangeSelect,
filters,
Expand All @@ -172,6 +176,8 @@ const Tile = forwardRef(
onAddAlertClick?: () => void;
onDeleteClick: () => void;
onUpdateChart?: (chart: Tile) => void;
onMoveToSection?: (containerId: string | undefined) => void;
containers?: DashboardContainer[];
onSettled?: () => void;
granularity: SQLInterval | undefined;
onTimeRangeSelect: (start: Date, end: Date) => void;
Expand Down Expand Up @@ -388,6 +394,40 @@ const Tile = forwardRef(
>
<IconPencil size={14} />
</ActionIcon>
{onMoveToSection &&
availableSections &&
availableSections.length > 0 && (
<Menu width={200} position="bottom-end">
<Menu.Target>
<ActionIcon
data-testid={`tile-move-section-button-${chart.id}`}
variant="subtle"
size="sm"
title="Move to Section"
>
<IconLayoutList size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Move to Section</Menu.Label>
{chart.containerId && (
<Menu.Item onClick={() => onMoveToSection(undefined)}>
(Ungrouped)
</Menu.Item>
)}
{availableSections
.filter(s => s.id !== chart.containerId)
.map(s => (
<Menu.Item
key={s.id}
onClick={() => onMoveToSection(s.id)}
>
{s.title}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
<ActionIcon
data-testid={`tile-delete-button-${chart.id}`}
variant="subtle"
Expand All @@ -403,12 +443,15 @@ const Tile = forwardRef(
alert,
alertIndicatorColor,
alertTooltip,
availableSections,
chart.config.displayType,
chart.id,
chart.containerId,
hovered,
onDeleteClick,
onDuplicateClick,
onEditClick,
onMoveToSection,
]);

const title = useMemo(
Expand Down Expand Up @@ -1056,7 +1099,19 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {

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

const onAddTile = () => {
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,
Expand All @@ -1067,16 +1122,33 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
...DEFAULT_CHART_CONFIG,
source: sources?.[0]?.id ?? '',
},
...(containerId ? { containerId } : {}),
});
};

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

const handleMoveTileToSection = useCallback(
(tileId: string, containerId: string | undefined) => {
if (!dashboard) return;
setDashboard(
produce(dashboard, draft => {
const tile = draft.tiles.find(t => t.id === tileId);
if (tile) {
if (containerId) tile.containerId = containerId;
else delete tile.containerId;
}
}),
);
},
[dashboard, setDashboard],
);

const renderTileComponent = useCallback(
(chart: Tile) => (
<Tile
Expand Down Expand Up @@ -1161,6 +1233,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
});
}
}}
containers={sections}
onMoveToSection={containerId =>
handleMoveTileToSection(chart.id, containerId)
}
/>
),
[
Expand All @@ -1176,6 +1252,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
whereLanguage,
onTimeRangeSelect,
filterQueries,
sections,
handleMoveTileToSection,
],
);

Expand Down Expand Up @@ -1211,26 +1289,85 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
// (same pattern as tile drag/resize). This matches Grafana and Kibana
// behavior where collapsed state is saved with the dashboard for all viewers.
const handleToggleSection = useCallback(
(sectionId: string) => {
(containerId: string) => {
if (!dashboard) return;
setDashboard(
produce(dashboard, draft => {
const section = draft.sections?.find(s => s.id === sectionId);
const section = draft.containers?.find(s => s.id === containerId);
if (section) section.collapsed = !section.collapsed;
}),
);
},
[dashboard, setDashboard],
);

// Group tiles by section; orphaned tiles (sectionId not matching any
const handleAddSection = useCallback(() => {
if (!dashboard) return;
setDashboard(
produce(dashboard, draft => {
if (!draft.containers) draft.containers = [];
draft.containers.push({
id: makeId(),
type: 'section',
title: 'New Section',
collapsed: false,
});
}),
);
}, [dashboard, setDashboard]);

const handleRenameSection = useCallback(
(containerId: string, newTitle: string) => {
if (!dashboard || !newTitle.trim()) return;
setDashboard(
produce(dashboard, draft => {
const section = draft.containers?.find(s => s.id === containerId);
if (section) section.title = newTitle.trim();
}),
);
},
[dashboard, setDashboard],
);

const handleDeleteSection = useCallback(
(containerId: string) => {
if (!dashboard) return;
setDashboard(
produce(dashboard, draft => {
// Find the bottom edge of existing ungrouped tiles so freed
// tiles are placed below them without collision.
const sectionIds = new Set(draft.containers?.map(c => c.id) ?? []);
let maxUngroupedY = 0;
for (const tile of draft.tiles) {
if (!tile.containerId || !sectionIds.has(tile.containerId)) {
maxUngroupedY = Math.max(maxUngroupedY, tile.y + tile.h);
}
}

for (const tile of draft.tiles) {
if (tile.containerId === containerId) {
tile.y += maxUngroupedY;
delete tile.containerId;
}
}

draft.containers = draft.containers?.filter(
s => s.id !== containerId,
);
}),
);
},
[dashboard, setDashboard],
);

// Group tiles by section; orphaned tiles (containerId not matching any
// section) fall back to ungrouped to avoid silently hiding them.
const tilesBySectionId = useMemo(() => {
const tilesByContainerId = useMemo(() => {
const map = new Map<string, Tile[]>();
for (const section of sections) {
map.set(
section.id,
allTiles.filter(t => t.sectionId === section.id),
allTiles.filter(t => t.containerId === section.id),
);
}
return map;
Expand All @@ -1240,10 +1377,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
() =>
hasSections
? allTiles.filter(
t => !t.sectionId || !tilesBySectionId.has(t.sectionId),
t => !t.containerId || !tilesByContainerId.has(t.containerId),
)
: allTiles,
[hasSections, allTiles, tilesBySectionId],
[hasSections, allTiles, tilesByContainerId],
);

const onUngroupedLayoutChange = useMemo(
Expand All @@ -1254,11 +1391,11 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
const sectionLayoutChangeHandlers = useMemo(() => {
const map = new Map<string, (newLayout: RGL.Layout[]) => void>();
for (const section of sections) {
const tiles = tilesBySectionId.get(section.id) ?? [];
const tiles = tilesByContainerId.get(section.id) ?? [];
map.set(section.id, makeOnLayoutChange(tiles));
}
return map;
}, [sections, tilesBySectionId, makeOnLayoutChange]);
}, [sections, tilesByContainerId, makeOnLayoutChange]);

const deleteDashboard = useDeleteDashboard();

Expand Down Expand Up @@ -1452,6 +1589,13 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
>
{hasTiles ? 'Import New Dashboard' : 'Import Dashboard'}
</Menu.Item>
<Menu.Item
data-testid="add-new-section-button"
leftSection={<IconLayoutList size={16} />}
onClick={handleAddSection}
>
Add Section
</Menu.Item>
Comment on lines +1592 to +1598

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.

One bit of feedback - this is hard to find.

Maybe it could be a second button next to Add New Tile?

Image

Also, the Add New Tile button being below the sections while still adding the new tile above the sections is somewhat unintuitive.

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.

I'll propose improving this in a separate PR!

<Menu.Divider />
<Menu.Item
data-testid="save-default-query-filters-menu-item"
Expand Down Expand Up @@ -1607,13 +1751,18 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
</ReactGridLayout>
)}
{sections.map(section => {
const sectionTiles = tilesBySectionId.get(section.id) ?? [];
const sectionTiles = tilesByContainerId.get(section.id) ?? [];
return (
<div key={section.id}>
<SectionHeader
section={section}
tileCount={sectionTiles.length}
onToggle={() => handleToggleSection(section.id)}
onRename={newTitle =>
handleRenameSection(section.id, newTitle)
}
onDelete={() => handleDeleteSection(section.id)}
onAddTile={() => onAddTile(section.id)}
/>
{!section.collapsed && sectionTiles.length > 0 && (
<ReactGridLayout
Expand Down Expand Up @@ -1651,7 +1800,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
variant={dashboard?.tiles.length === 0 ? 'primary' : 'secondary'}
mt="sm"
fw={400}
onClick={onAddTile}
onClick={() => onAddTile()}
w="100%"
>
+ Add New Tile
Expand Down
Loading
Loading