Compare commits

...

2 Commits

7 changed files with 156 additions and 6 deletions

View File

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

View File

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

View File

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

View File

@@ -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],
);
}

View File

@@ -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} />;

View File

@@ -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);
});
});

View File

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