mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-08 07:30:40 +01:00
Compare commits
2 Commits
main
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74785401b3 | ||
|
|
3b6625cb41 |
@@ -9,6 +9,7 @@ import { useCreatePanel } from '../../../hooks/useCreatePanel';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { useCloneSection } from '../hooks/useCloneSection';
|
||||
import { useDeleteSection } from '../hooks/useDeleteSection';
|
||||
import { useRenameSection } from '../hooks/useRenameSection';
|
||||
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
|
||||
@@ -67,6 +68,8 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
setIsDeleteOpen(false);
|
||||
}, [deleteSection]);
|
||||
|
||||
const cloneSection = useCloneSection();
|
||||
|
||||
const grid = (
|
||||
<SectionGrid
|
||||
items={section.items}
|
||||
@@ -109,6 +112,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
? {
|
||||
onRename: (): void => setIsRenaming(true),
|
||||
onAddPanel: (): void => openPicker(section.layoutIndex),
|
||||
onCloneSection: (): void => void cloneSection(section),
|
||||
onDeleteSection: (): void => setIsDeleteOpen(true),
|
||||
}
|
||||
: undefined
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Copy, EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
@@ -10,6 +10,7 @@ interface SectionActionsMenuProps {
|
||||
sectionId: string;
|
||||
onAddPanel?: () => void;
|
||||
onRename?: () => void;
|
||||
onCloneSection?: () => void;
|
||||
onDeleteSection?: () => void;
|
||||
}
|
||||
|
||||
@@ -17,6 +18,7 @@ function SectionActionsMenu({
|
||||
sectionId,
|
||||
onAddPanel,
|
||||
onRename,
|
||||
onCloneSection,
|
||||
onDeleteSection,
|
||||
}: SectionActionsMenuProps): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
@@ -37,6 +39,14 @@ function SectionActionsMenu({
|
||||
onClick: onRename,
|
||||
});
|
||||
}
|
||||
if (onCloneSection) {
|
||||
result.push({
|
||||
key: 'clone-section',
|
||||
icon: <Copy size={14} />,
|
||||
label: 'Clone section',
|
||||
onClick: onCloneSection,
|
||||
});
|
||||
}
|
||||
if (onDeleteSection) {
|
||||
result.push(
|
||||
{ type: 'divider' },
|
||||
@@ -50,7 +60,7 @@ function SectionActionsMenu({
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [onAddPanel, onRename, onDeleteSection]);
|
||||
}, [onAddPanel, onRename, onCloneSection, onDeleteSection]);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface SectionDragHandle {
|
||||
export interface SectionHeaderActions {
|
||||
onRename: () => void;
|
||||
onAddPanel: () => void;
|
||||
onCloneSection: () => void;
|
||||
onDeleteSection: () => void;
|
||||
}
|
||||
|
||||
@@ -81,6 +82,7 @@ function SectionHeader({
|
||||
sectionId={sectionId}
|
||||
onAddPanel={actions.onAddPanel}
|
||||
onRename={actions.onRename}
|
||||
onCloneSection={actions.onCloneSection}
|
||||
onDeleteSection={actions.onDeleteSection}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { cloneSectionOps } from '../../../patchOps';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
|
||||
/**
|
||||
* Duplicates a section with all its panels: each panel is deep-copied under a
|
||||
* fresh id and a new titled Grid ("<name> (Copy)") referencing them is appended,
|
||||
* as one atomic patch.
|
||||
*/
|
||||
export function useCloneSection(): (
|
||||
section: DashboardSection,
|
||||
) => Promise<void> {
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
|
||||
return useCallback(
|
||||
async (section: DashboardSection): Promise<void> => {
|
||||
const panels = section.items.flatMap((item) =>
|
||||
item.panel
|
||||
? [
|
||||
{
|
||||
newId: uuid(),
|
||||
panel: cloneDeep(item.panel),
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const title = section.title ? `${section.title} (Copy)` : 'Section (Copy)';
|
||||
const clone = patchAsync(cloneSectionOps(title, panels));
|
||||
|
||||
toast.promise(clone, {
|
||||
loading: 'Cloning section…',
|
||||
success: 'Section cloned',
|
||||
error: 'Failed to clone section',
|
||||
position: 'top-center',
|
||||
});
|
||||
|
||||
try {
|
||||
await clone;
|
||||
} catch {
|
||||
// toast.promise owns the error UX; the optimistic write + settle handle state.
|
||||
}
|
||||
},
|
||||
[patchAsync],
|
||||
);
|
||||
}
|
||||
@@ -41,14 +41,17 @@ function PanelsAndSectionsLayout({
|
||||
[layouts, panels],
|
||||
);
|
||||
|
||||
const isEmpty =
|
||||
sections.length === 0 || sections.every((s) => s.items.length === 0);
|
||||
|
||||
// Sectioned mode = at least one titled layout. Sections then become a
|
||||
// reorderable list; otherwise the dashboard is a single free-flowing grid
|
||||
// with no section chrome or reordering.
|
||||
const isSectioned = useMemo(() => sections.some((s) => !!s.title), [sections]);
|
||||
|
||||
// A titled section renders even with no panels (its header + add-panel state);
|
||||
// only show the dashboard empty state when nothing is titled and there are no panels.
|
||||
const isEmpty =
|
||||
sections.length === 0 ||
|
||||
(!isSectioned && sections.every((s) => s.items.length === 0));
|
||||
|
||||
const renderContent = (): ReactNode => {
|
||||
if (isEmpty) {
|
||||
return <DashboardEmptyState canAddPanel={isEditable} />;
|
||||
|
||||
@@ -3,7 +3,11 @@ import type {
|
||||
DashboardtypesLayoutDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { createDefaultPanel, createPanelOps } from '../patchOps';
|
||||
import {
|
||||
cloneSectionOps,
|
||||
createDefaultPanel,
|
||||
createPanelOps,
|
||||
} from '../patchOps';
|
||||
|
||||
function item(y: number, height: number): DashboardGridItemDTO {
|
||||
return { x: 0, y, width: 6, height, content: { $ref: '#/spec/panels/x' } };
|
||||
@@ -140,3 +144,39 @@ describe('createPanelOps', () => {
|
||||
expect((ops[2].value as DashboardGridItemDTO).y).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneSectionOps', () => {
|
||||
const panel = createDefaultPanel('signoz/TimeSeriesPanel');
|
||||
|
||||
it('adds a fresh panel per source panel + a titled Grid referencing them, geometry preserved', () => {
|
||||
const ops = cloneSectionOps('Overview (Copy)', [
|
||||
{ newId: 'n1', panel, x: 0, y: 0, width: 6, height: 4 },
|
||||
{ newId: 'n2', panel, x: 6, y: 0, width: 6, height: 4 },
|
||||
]);
|
||||
|
||||
// One add per panel, then the layout append.
|
||||
expect(ops).toHaveLength(3);
|
||||
expect(ops[0]).toMatchObject({ op: 'add', path: '/spec/panels/n1' });
|
||||
expect(ops[1]).toMatchObject({ op: 'add', path: '/spec/panels/n2' });
|
||||
|
||||
const layoutOp = ops[2];
|
||||
expect(layoutOp).toMatchObject({ op: 'add', path: '/spec/layouts/-' });
|
||||
const layout = layoutOp.value as DashboardtypesLayoutDTO;
|
||||
expect(layout.spec?.display?.title).toBe('Overview (Copy)');
|
||||
expect(layout.spec?.items).toHaveLength(2);
|
||||
expect(layout.spec?.items?.[1]).toMatchObject({
|
||||
x: 6,
|
||||
y: 0,
|
||||
width: 6,
|
||||
height: 4,
|
||||
content: { $ref: '#/spec/panels/n2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('produces just the empty titled Grid when the section has no panels', () => {
|
||||
const ops = cloneSectionOps('Empty (Copy)', []);
|
||||
expect(ops).toHaveLength(1);
|
||||
expect(ops[0]).toMatchObject({ op: 'add', path: '/spec/layouts/-' });
|
||||
expect((ops[0].value as DashboardtypesLayoutDTO).spec?.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,42 @@ export function addSectionOp(
|
||||
return { op: add, path: '/spec/layouts/-', value: newGridLayout(title) };
|
||||
}
|
||||
|
||||
interface ClonedSectionPanel {
|
||||
newId: string;
|
||||
/** Deep-copied source panel spec (caller owns the clone). */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Clone a section: add fresh panel copies and append a titled Grid referencing them. */
|
||||
export function cloneSectionOps(
|
||||
title: string,
|
||||
panels: ClonedSectionPanel[],
|
||||
): DashboardtypesJSONPatchOperationDTO[] {
|
||||
const panelOps = panels.map((p) => ({
|
||||
op: add,
|
||||
path: `/spec/panels/${p.newId}`,
|
||||
value: p.panel,
|
||||
}));
|
||||
const layout: DashboardtypesLayoutDTO = {
|
||||
kind: 'Grid' as DashboardtypesLayoutDTO['kind'],
|
||||
spec: {
|
||||
display: { title },
|
||||
items: panels.map((p) => ({
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
content: { $ref: panelRef(p.newId) },
|
||||
})),
|
||||
},
|
||||
};
|
||||
return [...panelOps, { op: add, path: '/spec/layouts/-', value: layout }];
|
||||
}
|
||||
|
||||
interface AddPanelToSectionArgs {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
|
||||
Reference in New Issue
Block a user