diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.tsx index b5d2ae3a91e..26b9f526097 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.tsx +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.tsx @@ -6,6 +6,10 @@ import { EllipsisVertical } from '@signozhq/icons'; import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas'; import cx from 'classnames'; +import type { DashboardSection } from '../../utils'; +import type { DeletePanelArgs } from './hooks/useDeletePanel'; +import type { MovePanelArgs } from './hooks/useMovePanelToSection'; +import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu'; import styles from './Panel.module.scss'; interface Props { @@ -17,9 +21,22 @@ interface Props { * data. Currently unused on purpose. */ isVisible?: boolean; + /** Section actions — present only in editable sectioned mode. */ + currentLayoutIndex?: number; + sections?: DashboardSection[]; + onMovePanel?: (args: MovePanelArgs) => void; + onDeletePanel?: (args: DeletePanelArgs) => void; } -function Panel({ panel, panelId, isVisible }: Props): JSX.Element { +function Panel({ + panel, + panelId, + isVisible, + currentLayoutIndex, + sections, + onMovePanel, + onDeletePanel, +}: Props): JSX.Element { const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`; const description = panel?.spec?.display?.description; const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown'; @@ -48,7 +65,17 @@ function Panel({ panel, panelId, isVisible }: Props): JSX.Element { {kind} - + {currentLayoutIndex !== undefined && (onMovePanel || onDeletePanel) ? ( + + ) : ( + + )}
diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelActionsMenu/PanelActionsMenu.module.scss b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelActionsMenu/PanelActionsMenu.module.scss new file mode 100644 index 00000000000..8bd080b577f --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelActionsMenu/PanelActionsMenu.module.scss @@ -0,0 +1,16 @@ +.trigger { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px; + background: transparent; + border: none; + border-radius: 2px; + color: var(--bg-vanilla-400, #8993ae); + cursor: pointer; + + &:hover { + color: var(--bg-vanilla-100, #fff); + background: var(--bg-slate-400, #1d212d); + } +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelActionsMenu/PanelActionsMenu.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelActionsMenu/PanelActionsMenu.tsx new file mode 100644 index 00000000000..8465801c552 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelActionsMenu/PanelActionsMenu.tsx @@ -0,0 +1,95 @@ +import { useMemo } from 'react'; +import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons'; +import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu'; +import type { MenuItem } from '@signozhq/ui/dropdown-menu'; + +import type { DashboardSection } from '../../../utils'; +import type { DeletePanelArgs } from '../hooks/useDeletePanel'; +import type { MovePanelArgs } from '../hooks/useMovePanelToSection'; +import styles from './PanelActionsMenu.module.scss'; + +interface Props { + panelId: string; + currentLayoutIndex: number; + sections: DashboardSection[]; + onMovePanel?: (args: MovePanelArgs) => void; + onDeletePanel?: (args: DeletePanelArgs) => void; +} + +function PanelActionsMenu({ + panelId, + currentLayoutIndex, + sections, + onMovePanel, + onDeletePanel, +}: Props): JSX.Element { + const items = useMemo(() => { + const result: MenuItem[] = []; + + if (onMovePanel) { + const targets = sections.filter( + (s) => s.title && s.layoutIndex !== currentLayoutIndex, + ); + if (targets.length === 0) { + result.push({ + key: 'move', + label: 'Move to section', + icon: , + disabled: true, + }); + } else { + result.push({ + key: 'move', + label: 'Move to section', + icon: , + children: targets.map((s) => ({ + key: `move-${s.layoutIndex}`, + label: s.title, + onClick: (): void => + onMovePanel({ + panelId, + fromLayoutIndex: currentLayoutIndex, + toLayoutIndex: s.layoutIndex, + }), + })), + }); + } + } + + if (onDeletePanel) { + if (result.length > 0) { + result.push({ type: 'divider' }); + } + result.push({ + key: 'delete-panel', + danger: true, + icon: , + label: 'Delete panel', + onClick: (): void => + onDeletePanel({ panelId, layoutIndex: currentLayoutIndex }), + }); + } + + return result; + }, [sections, currentLayoutIndex, panelId, onMovePanel, onDeletePanel]); + + return ( + + + + ); +} + +export default PanelActionsMenu; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal.module.scss b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal.module.scss new file mode 100644 index 00000000000..9562a779af1 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal.module.scss @@ -0,0 +1,22 @@ +.grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} + +.typeButton { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + background: var(--bg-ink-400, #0b0c0e); + border: 1px solid var(--bg-slate-400, #1d212d); + border-radius: 4px; + color: var(--bg-vanilla-100, #fff); + cursor: pointer; + text-align: left; + + &:hover { + border-color: var(--bg-robin-500); + } +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal.tsx new file mode 100644 index 00000000000..8c8fdfb33e6 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal.tsx @@ -0,0 +1,82 @@ +import { Modal } from 'antd'; +import { + BarChart, + ChartLine, + ChartPie, + Hash, + List, + Table, +} from '@signozhq/icons'; + +import styles from './PanelTypeSelectionModal.module.scss'; + +interface PanelType { + pluginKind: string; + label: string; + icon: JSX.Element; +} + +const PANEL_TYPES: PanelType[] = [ + { + pluginKind: 'signoz/TimeSeriesPanel', + label: 'Time Series', + icon: , + }, + { pluginKind: 'signoz/NumberPanel', label: 'Value', icon: }, + { pluginKind: 'signoz/TablePanel', label: 'Table', icon: }, + { + pluginKind: 'signoz/BarChartPanel', + label: 'Bar Chart', + icon: , + }, + { + pluginKind: 'signoz/PieChartPanel', + label: 'Pie Chart', + icon: , + }, + { + pluginKind: 'signoz/HistogramPanel', + label: 'Histogram', + icon: , + }, + { pluginKind: 'signoz/ListPanel', label: 'List', icon: }, +]; + +interface Props { + open: boolean; + onClose: () => void; + onSelect: (pluginKind: string) => void; +} + +function PanelTypeSelectionModal({ + open, + onClose, + onSelect, +}: Props): JSX.Element { + return ( + +
+ {PANEL_TYPES.map((type) => ( + + ))} +
+
+ ); +} + +export default PanelTypeSelectionModal; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useAddPanelToSection.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useAddPanelToSection.ts new file mode 100644 index 00000000000..78616f60d89 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useAddPanelToSection.ts @@ -0,0 +1,76 @@ +import { useCallback } from 'react'; +import { v4 as uuid } from 'uuid'; + +import { patchDashboardV2 } from 'api/generated/services/dashboard'; +import { useErrorModal } from 'providers/ErrorModalProvider'; +import APIError from 'types/api/error'; + +import { + addPanelToSectionOps, + createDefaultPanel, + panelRef, +} from '../../../patchOps'; +import { useDashboardStore } from '../../../store/useDashboardStore'; +import type { DashboardSection } from '../../../utils'; + +interface Params { + sections: DashboardSection[]; +} + +export interface AddPanelArgs { + layoutIndex: number; + pluginKind: string; +} + +/** + * Creates a new panel and places its item ref at the bottom of the target + * section, as one atomic patch. Structure-only: the panel is a valid minimal + * placeholder (its query is filled in once the panel editor lands). + */ +export function useAddPanelToSection({ + sections, +}: Params): (args: AddPanelArgs) => Promise { + const dashboardId = useDashboardStore((s) => s.dashboardId); + const refetch = useDashboardStore((s) => s.refetch); + const { showErrorModal } = useErrorModal(); + + return useCallback( + async ({ layoutIndex, pluginKind }: AddPanelArgs): Promise => { + if (!dashboardId) { + return; + } + const target = sections.find((s) => s.layoutIndex === layoutIndex); + if (!target) { + return; + } + + const panelId = uuid(); + const nextY = target.items.reduce( + (max, i) => Math.max(max, i.y + i.height), + 0, + ); + + try { + await patchDashboardV2( + { id: dashboardId }, + addPanelToSectionOps({ + panelId, + panel: createDefaultPanel(pluginKind), + layoutIndex, + item: { + x: 0, + y: nextY, + width: 6, + height: 6, + content: { $ref: panelRef(panelId) }, + }, + }), + ); + refetch(); + } catch (error) { + showErrorModal(error as APIError); + } + }, + [sections, dashboardId, refetch, showErrorModal], + ); +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDeletePanel.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDeletePanel.ts new file mode 100644 index 00000000000..30dded7a9ca --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDeletePanel.ts @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; + +import { patchDashboardV2 } from 'api/generated/services/dashboard'; +import { useErrorModal } from 'providers/ErrorModalProvider'; +import APIError from 'types/api/error'; + +import { removePanelOp, replaceSectionItemsOp } from '../../../patchOps'; +import { useDashboardStore } from '../../../store/useDashboardStore'; +import type { DashboardSection } from '../../../utils'; + +interface Params { + sections: DashboardSection[]; +} + +export interface DeletePanelArgs { + panelId: string; + layoutIndex: number; +} + +/** + * Removes a panel: drops its item ref from the section's items and deletes the + * panel from `spec.panels`, as one atomic patch. + */ +export function useDeletePanel({ + sections, +}: Params): (args: DeletePanelArgs) => Promise { + const dashboardId = useDashboardStore((s) => s.dashboardId); + const refetch = useDashboardStore((s) => s.refetch); + const { showErrorModal } = useErrorModal(); + + return useCallback( + async ({ panelId, layoutIndex }: DeletePanelArgs): Promise => { + if (!dashboardId) { + return; + } + const section = sections.find((s) => s.layoutIndex === layoutIndex); + if (!section) { + return; + } + + const nextItems = section.items.filter((i) => i.id !== panelId); + try { + await patchDashboardV2({ id: dashboardId }, [ + replaceSectionItemsOp(layoutIndex, nextItems), + removePanelOp(panelId), + ]); + refetch(); + } catch (error) { + showErrorModal(error as APIError); + } + }, + [sections, dashboardId, refetch, showErrorModal], + ); +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useMovePanelToSection.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useMovePanelToSection.ts new file mode 100644 index 00000000000..1e5953308fe --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useMovePanelToSection.ts @@ -0,0 +1,79 @@ +import { useCallback } from 'react'; + +import { patchDashboardV2 } from 'api/generated/services/dashboard'; +import { useErrorModal } from 'providers/ErrorModalProvider'; +import APIError from 'types/api/error'; + +import { movePanelBetweenSectionsOps } from '../../../patchOps'; +import { useDashboardStore } from '../../../store/useDashboardStore'; +import type { DashboardSection } from '../../../utils'; + +export interface MovePanelArgs { + panelId: string; + fromLayoutIndex: number; + toLayoutIndex: number; +} + +interface Params { + sections: DashboardSection[]; +} + +/** + * Relocates a panel's item ref from one section to another. The panel itself + * stays in `spec.panels`; only the grid item moves, dropped into a free row at + * the bottom of the target section. Persisted as one atomic patch. + */ +export function useMovePanelToSection({ + sections, +}: Params): (args: MovePanelArgs) => Promise { + const dashboardId = useDashboardStore((s) => s.dashboardId); + const refetch = useDashboardStore((s) => s.refetch); + const { showErrorModal } = useErrorModal(); + + return useCallback( + async ({ + panelId, + fromLayoutIndex, + toLayoutIndex, + }: MovePanelArgs): Promise => { + if (!dashboardId || fromLayoutIndex === toLayoutIndex) { + return; + } + + const source = sections.find((s) => s.layoutIndex === fromLayoutIndex); + const target = sections.find((s) => s.layoutIndex === toLayoutIndex); + if (!source || !target) { + return; + } + + const moved = source.items.find((i) => i.id === panelId); + if (!moved) { + return; + } + + const sourceItems = source.items.filter((i) => i.id !== panelId); + // Place at a fresh row at the bottom of the target section. + const nextY = target.items.reduce( + (max, i) => Math.max(max, i.y + i.height), + 0, + ); + const targetItems = [...target.items, { ...moved, x: 0, y: nextY }]; + + try { + await patchDashboardV2( + { id: dashboardId }, + movePanelBetweenSectionsOps({ + sourceIndex: fromLayoutIndex, + sourceItems, + targetIndex: toLayoutIndex, + targetItems, + }), + ); + refetch(); + } catch (error) { + showErrorModal(error as APIError); + } + }, + [sections, dashboardId, refetch, showErrorModal], + ); +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/AddSectionControl/AddSectionControl.module.scss b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/AddSectionControl/AddSectionControl.module.scss new file mode 100644 index 00000000000..4f1be888faf --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/AddSectionControl/AddSectionControl.module.scss @@ -0,0 +1,17 @@ +.addButton { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 8px; + padding: 8px 12px; + background: transparent; + border: 1px dashed var(--bg-slate-400, #1d212d); + border-radius: 4px; + color: var(--bg-vanilla-400, #8993ae); + cursor: pointer; + + &:hover { + border-color: var(--bg-robin-500); + color: var(--bg-vanilla-100, #fff); + } +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/AddSectionControl/AddSectionControl.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/AddSectionControl/AddSectionControl.tsx new file mode 100644 index 00000000000..10b6dee24a4 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/AddSectionControl/AddSectionControl.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; +import { Plus } from '@signozhq/icons'; +import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas'; + +import type { DashboardSection } from '../../../utils'; +import { useAddSection } from '../hooks/useAddSection'; +import { useFirstSectionMigration } from '../hooks/useFirstSectionMigration'; +import FirstSectionMigrationModal from '../FirstSectionMigrationModal'; +import styles from './AddSectionControl.module.scss'; + +const DEFAULT_SECTION_TITLE = 'New section'; + +interface Props { + sections: DashboardSection[]; + layouts: DashboardtypesLayoutDTO[] | undefined | null; + isSectioned: boolean; +} + +function AddSectionControl({ + sections, + layouts, + isSectioned, +}: Props): JSX.Element { + const [isMigrationOpen, setIsMigrationOpen] = useState(false); + const { addSection } = useAddSection({ layouts }); + const { migrate, isSaving } = useFirstSectionMigration({ sections }); + + // Free-flowing dashboard with existing panels → must migrate before sections + // can coexist (every panel must belong to a section once any exists). + const needsMigration = + !isSectioned && sections.some((s) => s.items.length > 0); + + const handleClick = (): void => { + if (needsMigration) { + setIsMigrationOpen(true); + return; + } + void addSection(DEFAULT_SECTION_TITLE); + }; + + const handleConfirmMigration = async (): Promise => { + await migrate(DEFAULT_SECTION_TITLE); + setIsMigrationOpen(false); + }; + + return ( + <> + + setIsMigrationOpen(false)} + onConfirm={handleConfirmMigration} + /> + + ); +} + +export default AddSectionControl; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/FirstSectionMigrationModal.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/FirstSectionMigrationModal.tsx new file mode 100644 index 00000000000..9e9f4b7a5f0 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/FirstSectionMigrationModal.tsx @@ -0,0 +1,41 @@ +import { Modal } from 'antd'; +import { Typography } from '@signozhq/ui/typography'; + +interface Props { + open: boolean; + isSaving: boolean; + onClose: () => void; + onConfirm: () => void; +} + +/** + * Shown when the user adds the first section to a free-flowing dashboard that + * already has panels. Confirms grouping the existing panels into a section + * before proceeding. + */ +function FirstSectionMigrationModal({ + open, + isSaving, + onClose, + onConfirm, +}: Props): JSX.Element { + return ( + + + This dashboard's panels are currently free-flowing. Adding a section + will move the existing panels into their own section, and a new empty + section will be added below. You can rename sections afterwards. + + + ); +} + +export default FirstSectionMigrationModal; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/RenameSectionModal.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/RenameSectionModal.tsx new file mode 100644 index 00000000000..779d7f9d8b8 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/RenameSectionModal.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; +import { Modal } from 'antd'; +import { Input } from '@signozhq/ui/input'; + +interface Props { + open: boolean; + initialValue: string; + isSaving: boolean; + onClose: () => void; + onSubmit: (title: string) => void; +} + +function RenameSectionModal({ + open, + initialValue, + isSaving, + onClose, + onSubmit, +}: Props): JSX.Element { + const [value, setValue] = useState(initialValue); + + // Reseed the field each time the modal opens. + useEffect(() => { + if (open) { + setValue(initialValue); + } + }, [open, initialValue]); + + const submit = (): void => { + const trimmed = value.trim(); + if (trimmed) { + onSubmit(trimmed); + } + }; + + return ( + + setValue(e.target.value)} + onKeyDown={(e): void => { + if (e.key === 'Enter') { + e.preventDefault(); + submit(); + } + }} + /> + + ); +} + +export default RenameSectionModal; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/Section/Section.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/Section/Section.tsx index cd7380a4eb3..210e39d1375 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/Section/Section.tsx +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/Section/Section.tsx @@ -1,17 +1,45 @@ import { useRef, useState } from 'react'; +import { Modal } from 'antd'; import { useIntersectionObserver } from 'hooks/useIntersectionObserver'; import type { DashboardSection } from '../../../utils'; +import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection'; +import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel'; +import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection'; +import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal'; +import { useDashboardStore } from '../../../store/useDashboardStore'; +import { useDeleteSection } from '../hooks/useDeleteSection'; +import { useRenameSection } from '../hooks/useRenameSection'; +import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse'; +import RenameSectionModal from '../RenameSectionModal'; import SectionGrid from '../SectionGrid/SectionGrid'; -import SectionHeader from '../SectionHeader/SectionHeader'; +import SectionHeader, { + type SectionDragHandle, +} from '../SectionHeader/SectionHeader'; import styles from './Section.module.scss'; interface Props { section: DashboardSection; + /** Adds a panel to this section; present only in editable sectioned mode. */ + onAddPanel?: (args: AddPanelArgs) => void; + /** All sections + per-panel handlers, for the panel "Move to section" / delete actions. */ + sections?: DashboardSection[]; + onMovePanel?: (args: MovePanelArgs) => void; + onDeletePanel?: (args: DeletePanelArgs) => void; + /** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */ + dragHandle?: SectionDragHandle; } -function Section({ section }: Props): JSX.Element { +function Section({ + section, + onAddPanel, + sections, + onMovePanel, + onDeletePanel, + dragHandle, +}: Props): JSX.Element { + const isEditable = useDashboardStore((s) => s.isEditable); const containerRef = useRef(null); // Placeholder signal for lazy panel query-loading (consumed in a later PR): // true once the section scrolls into (or near) the viewport. @@ -19,10 +47,48 @@ function Section({ section }: Props): JSX.Element { rootMargin: '200px', }); - const [open, setOpen] = useState(section.open); - const toggle = (): void => setOpen((prev) => !prev); + const { open, toggle } = useToggleSectionCollapse({ sectionId: section.id }); - const grid = ; + const [isRenaming, setIsRenaming] = useState(false); + const { rename, isSaving } = useRenameSection({ + layoutIndex: section.layoutIndex, + }); + + const handleRenameSubmit = async (title: string): Promise => { + const ok = await rename(title); + if (ok) { + setIsRenaming(false); + } + }; + + const [isAddingPanel, setIsAddingPanel] = useState(false); + const handleSelectPanelType = (pluginKind: string): void => { + onAddPanel?.({ layoutIndex: section.layoutIndex, pluginKind }); + setIsAddingPanel(false); + }; + + const { deleteSection } = useDeleteSection({ section }); + const confirmDeleteSection = (): void => { + Modal.confirm({ + title: `Delete section "${section.title ?? ''}"?`, + content: 'Panels in this section will be removed.', + okText: 'Delete', + okButtonProps: { danger: true }, + centered: true, + onOk: () => deleteSection(), + }); + }; + + const grid = ( + + ); if (!section.title) { // Untitled section — just the grid (no header chrome), but still observed @@ -51,8 +117,26 @@ function Section({ section }: Props): JSX.Element { open={open} onToggle={toggle} repeatVariable={section.repeatVariable} + dragHandle={dragHandle} + onRename={isEditable ? (): void => setIsRenaming(true) : undefined} + onAddPanel={ + isEditable && onAddPanel ? (): void => setIsAddingPanel(true) : undefined + } + onDeleteSection={isEditable ? confirmDeleteSection : undefined} /> {open ? grid : null} + setIsRenaming(false)} + onSubmit={handleRenameSubmit} + /> + setIsAddingPanel(false)} + onSelect={handleSelectPanelType} + /> ); } diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionActionsMenu/SectionActionsMenu.module.scss b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionActionsMenu/SectionActionsMenu.module.scss new file mode 100644 index 00000000000..8bd080b577f --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionActionsMenu/SectionActionsMenu.module.scss @@ -0,0 +1,16 @@ +.trigger { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px; + background: transparent; + border: none; + border-radius: 2px; + color: var(--bg-vanilla-400, #8993ae); + cursor: pointer; + + &:hover { + color: var(--bg-vanilla-100, #fff); + background: var(--bg-slate-400, #1d212d); + } +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionActionsMenu/SectionActionsMenu.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionActionsMenu/SectionActionsMenu.tsx new file mode 100644 index 00000000000..b01c156f2bf --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionActionsMenu/SectionActionsMenu.tsx @@ -0,0 +1,68 @@ +import { useMemo } from 'react'; +import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons'; +import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu'; +import type { MenuItem } from '@signozhq/ui/dropdown-menu'; + +import styles from './SectionActionsMenu.module.scss'; + +interface Props { + sectionId: string; + onAddPanel?: () => void; + onRename?: () => void; + onDeleteSection?: () => void; +} + +function SectionActionsMenu({ + sectionId, + onAddPanel, + onRename, + onDeleteSection, +}: Props): JSX.Element { + const items = useMemo(() => { + const result: MenuItem[] = []; + if (onAddPanel) { + result.push({ + key: 'add-panel', + icon: , + label: 'Add panel', + onClick: onAddPanel, + }); + } + if (onRename) { + result.push({ + key: 'rename', + icon: , + label: 'Rename section', + onClick: onRename, + }); + } + if (onDeleteSection) { + result.push( + { type: 'divider' }, + { + key: 'delete-section', + danger: true, + icon: , + label: 'Delete section', + onClick: onDeleteSection, + }, + ); + } + return result; + }, [onAddPanel, onRename, onDeleteSection]); + + return ( + + + + ); +} + +export default SectionActionsMenu; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionDragPreview/SectionDragPreview.module.scss b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionDragPreview/SectionDragPreview.module.scss new file mode 100644 index 00000000000..de4ac9473dc --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionDragPreview/SectionDragPreview.module.scss @@ -0,0 +1,7 @@ +.preview { + border: 1px solid var(--bg-robin-500); + border-radius: 4px; + background: var(--bg-ink-400, #0b0c0e); + box-shadow: 0 8px 24px rgb(0 0 0 / 40%); + cursor: grabbing; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionDragPreview/SectionDragPreview.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionDragPreview/SectionDragPreview.tsx new file mode 100644 index 00000000000..d10d7689f72 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionDragPreview/SectionDragPreview.tsx @@ -0,0 +1,32 @@ +import type { DashboardSection } from '../../../utils'; +import SectionHeader from '../SectionHeader/SectionHeader'; +import styles from './SectionDragPreview.module.scss'; + +interface Props { + section: DashboardSection; +} + +/** + * Lightweight preview rendered inside the DragOverlay while a section is being + * dragged. Deliberately header-only (no react-grid-layout) so the overlay is + * cheap and never triggers RGL width re-measurement. + */ +function SectionDragPreview({ section }: Props): JSX.Element { + const panelCount = section.items.length; + const title = `${section.title ?? ''} · ${panelCount} ${ + panelCount === 1 ? 'panel' : 'panels' + }`; + + return ( +
+ undefined} + /> +
+ ); +} + +export default SectionDragPreview; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionGrid/SectionGrid.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionGrid/SectionGrid.tsx index 56f897b66ea..dc1a4acae02 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionGrid/SectionGrid.tsx +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionGrid/SectionGrid.tsx @@ -2,18 +2,35 @@ import { useMemo } from 'react'; import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout'; import type { DashboardSection } from '../../../utils'; +import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel'; +import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection'; import Panel from '../../Panel/Panel'; +import { useDashboardStore } from '../../../store/useDashboardStore'; +import { usePersistLayout } from '../hooks/usePersistLayout'; import styles from './SectionGrid.module.scss'; const ResponsiveGridLayout = WidthProvider(GridLayout); interface Props { items: DashboardSection['items']; + layoutIndex: number; /** Forwarded to panels — true when the parent section is in the viewport. */ isVisible?: boolean; + /** All sections + handlers — present only in editable sectioned mode (panel "Move to section" / delete). */ + sections?: DashboardSection[]; + onMovePanel?: (args: MovePanelArgs) => void; + onDeletePanel?: (args: DeletePanelArgs) => void; } -function SectionGrid({ items, isVisible }: Props): JSX.Element { +function SectionGrid({ + items, + layoutIndex, + isVisible, + sections, + onMovePanel, + onDeletePanel, +}: Props): JSX.Element { + const isEditable = useDashboardStore((s) => s.isEditable); const rglLayout = useMemo( () => items.map((item) => ({ @@ -26,6 +43,8 @@ function SectionGrid({ items, isVisible }: Props): JSX.Element { [items], ); + const { handleLayoutChange } = usePersistLayout({ layoutIndex, items }); + return ( {items.map((item) => (
- +
))}
diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionHeader/SectionHeader.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionHeader/SectionHeader.tsx index f1b9c4ecb4f..3e23c0429d9 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionHeader/SectionHeader.tsx +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionHeader/SectionHeader.tsx @@ -1,15 +1,29 @@ -import { ChevronDown, ChevronRight } from '@signozhq/icons'; +import type { DraggableAttributes } from '@dnd-kit/core'; +import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities'; +import { ChevronDown, ChevronRight, GripVertical } from '@signozhq/icons'; import { Typography } from '@signozhq/ui/typography'; import cx from 'classnames'; +import SectionActionsMenu from '../SectionActionsMenu/SectionActionsMenu'; import styles from './SectionHeader.module.scss'; +export interface SectionDragHandle { + attributes: DraggableAttributes; + listeners: SyntheticListenerMap | undefined; + setActivatorNodeRef: (element: HTMLElement | null) => void; +} + interface Props { sectionId: string; title: string; open: boolean; onToggle: () => void; repeatVariable?: string; + /** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */ + dragHandle?: SectionDragHandle; + onRename?: () => void; + onAddPanel?: () => void; + onDeleteSection?: () => void; } function SectionHeader({ @@ -18,9 +32,27 @@ function SectionHeader({ open, onToggle, repeatVariable, + dragHandle, + onRename, + onAddPanel, + onDeleteSection, }: Props): JSX.Element { + const hasActions = !!(onAddPanel || onRename || onDeleteSection); return (
+ {dragHandle ? ( + + ) : null} + {hasActions ? ( + + ) : null}
); } diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionList.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionList.tsx new file mode 100644 index 00000000000..c27a135d66c --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionList.tsx @@ -0,0 +1,102 @@ +import { useMemo } from 'react'; +import { closestCenter, DndContext, DragOverlay } from '@dnd-kit/core'; +import { + restrictToParentElement, + restrictToVerticalAxis, +} from '@dnd-kit/modifiers'; +import { + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas'; + +import type { DashboardSection } from '../../utils'; +import { useAddPanelToSection } from '../Panel/hooks/useAddPanelToSection'; +import { useDeletePanel } from '../Panel/hooks/useDeletePanel'; +import { useMovePanelToSection } from '../Panel/hooks/useMovePanelToSection'; +import { useDashboardStore } from '../../store/useDashboardStore'; +import { useSectionDragReorder } from './hooks/useSectionDragReorder'; +import Section from './Section/Section'; +import SectionDragPreview from './SectionDragPreview/SectionDragPreview'; +import SortableSection from './SortableSection'; + +interface Props { + sections: DashboardSection[]; + layouts: DashboardtypesLayoutDTO[] | undefined | null; +} + +function SectionList({ sections, layouts }: Props): JSX.Element { + const isEditable = useDashboardStore((s) => s.isEditable); + + const { + sensors, + orderedSections, + activeSection, + onDragStart, + onDragEnd, + onDragCancel, + } = useSectionDragReorder({ sections, layouts }); + + const onAddPanel = useAddPanelToSection({ sections }); + const onMovePanel = useMovePanelToSection({ sections }); + const onDeletePanel = useDeletePanel({ sections }); + + // Only titled sections participate in reordering; untitled (free-flow) + // blocks render in place without a drag handle. + const sortableIds = useMemo( + () => orderedSections.filter((s) => s.title).map((s) => s.id), + [orderedSections], + ); + + if (!isEditable) { + return ( + <> + {sections.map((section) => ( +
+ ))} + + ); + } + + return ( + + + {orderedSections.map((section) => + section.title ? ( + + ) : ( +
+ ), + )} + + {/* dropAnimation disabled: optimistic reorder already places the section, + so animating the overlay back would cause a visible snap/shake. */} + + {activeSection ? : null} + + + ); +} + +export default SectionList; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SortableSection.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SortableSection.tsx new file mode 100644 index 00000000000..40f8f79b8a1 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SortableSection.tsx @@ -0,0 +1,59 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + +import type { DashboardSection } from '../../utils'; +import type { AddPanelArgs } from '../Panel/hooks/useAddPanelToSection'; +import type { DeletePanelArgs } from '../Panel/hooks/useDeletePanel'; +import type { MovePanelArgs } from '../Panel/hooks/useMovePanelToSection'; +import Section from './Section/Section'; + +interface Props { + section: DashboardSection; + sections: DashboardSection[]; + onAddPanel: (args: AddPanelArgs) => void; + onMovePanel: (args: MovePanelArgs) => void; + onDeletePanel: (args: DeletePanelArgs) => void; +} + +function SortableSection({ + section, + sections, + onAddPanel, + onMovePanel, + onDeletePanel, +}: Props): JSX.Element { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: section.id }); + + // dnd-kit drives the drag transform per-frame, so this must be an inline + // style — there is no static-stylesheet equivalent for a live transform. + // While dragging, the original is hidden (the DragOverlay renders the moving + // preview); keeping it in place preserves the gap and lets siblings animate. + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0 : undefined, + }; + + return ( +
+
+
+ ); +} + +export default SortableSection; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useAddSection.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useAddSection.ts new file mode 100644 index 00000000000..d053d83a3ed --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useAddSection.ts @@ -0,0 +1,59 @@ +import { useCallback, useState } from 'react'; + +import { patchDashboardV2 } from 'api/generated/services/dashboard'; +import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas'; +import { useErrorModal } from 'providers/ErrorModalProvider'; +import APIError from 'types/api/error'; + +import { + addSectionOp, + newGridLayout, + reorderLayoutsOp, +} from '../../../patchOps'; +import { useDashboardStore } from '../../../store/useDashboardStore'; + +interface Params { + layouts: DashboardtypesLayoutDTO[] | undefined | null; +} + +interface Result { + addSection: (title: string) => Promise; + isSaving: boolean; +} + +/** + * Appends an empty titled section. When the dashboard has no layouts yet, the + * layouts array is created via a `replace` (an `add` to a missing/empty array + * pointer is unreliable); otherwise a new Grid is appended. + */ +export function useAddSection({ layouts }: Params): Result { + const dashboardId = useDashboardStore((s) => s.dashboardId); + const refetch = useDashboardStore((s) => s.refetch); + const [isSaving, setIsSaving] = useState(false); + const { showErrorModal } = useErrorModal(); + + const addSection = useCallback( + async (title: string): Promise => { + const trimmed = title.trim(); + if (!dashboardId || !trimmed) { + return; + } + const op = + !layouts || layouts.length === 0 + ? reorderLayoutsOp([newGridLayout(trimmed)]) + : addSectionOp(trimmed); + try { + setIsSaving(true); + await patchDashboardV2({ id: dashboardId }, [op]); + refetch(); + } catch (error) { + showErrorModal(error as APIError); + } finally { + setIsSaving(false); + } + }, + [layouts, dashboardId, refetch, showErrorModal], + ); + + return { addSection, isSaving }; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useDeleteSection.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useDeleteSection.ts new file mode 100644 index 00000000000..1280004a9dc --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useDeleteSection.ts @@ -0,0 +1,51 @@ +import { useCallback, useState } from 'react'; + +import { patchDashboardV2 } from 'api/generated/services/dashboard'; +import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas'; +import { useErrorModal } from 'providers/ErrorModalProvider'; +import APIError from 'types/api/error'; + +import { removePanelOp, removeSectionOp } from '../../../patchOps'; +import { useDashboardStore } from '../../../store/useDashboardStore'; +import type { DashboardSection } from '../../../utils'; + +interface Params { + section: DashboardSection; +} + +interface Result { + deleteSection: () => Promise; + isSaving: boolean; +} + +/** + * Deletes a section: removes its Grid layout and deletes every panel it + * contained from `spec.panels` (orphan cleanup), as one atomic patch. + */ +export function useDeleteSection({ section }: Params): Result { + const dashboardId = useDashboardStore((s) => s.dashboardId); + const refetch = useDashboardStore((s) => s.refetch); + const [isSaving, setIsSaving] = useState(false); + const { showErrorModal } = useErrorModal(); + + const deleteSection = useCallback(async (): Promise => { + if (!dashboardId) { + return; + } + const ops: DashboardtypesJSONPatchOperationDTO[] = section.items.map((i) => + removePanelOp(i.id), + ); + ops.push(removeSectionOp(section.layoutIndex)); + try { + setIsSaving(true); + await patchDashboardV2({ id: dashboardId }, ops); + refetch(); + } catch (error) { + showErrorModal(error as APIError); + } finally { + setIsSaving(false); + } + }, [section, dashboardId, refetch, showErrorModal]); + + return { deleteSection, isSaving }; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useFirstSectionMigration.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useFirstSectionMigration.ts new file mode 100644 index 00000000000..85fe5a60031 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useFirstSectionMigration.ts @@ -0,0 +1,64 @@ +import { useCallback, useState } from 'react'; + +import { patchDashboardV2 } from 'api/generated/services/dashboard'; +import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas'; +import { useErrorModal } from 'providers/ErrorModalProvider'; +import APIError from 'types/api/error'; + +import { addSectionOp, titleUntitledSectionOp } from '../../../patchOps'; +import { useDashboardStore } from '../../../store/useDashboardStore'; +import type { DashboardSection } from '../../../utils'; + +interface Params { + sections: DashboardSection[]; +} + +interface Result { + migrate: (newSectionTitle: string) => Promise; + isSaving: boolean; +} + +/** + * Converts a free-flowing dashboard into a sectioned one: every existing + * untitled layout that holds panels is titled in place ("Section 1", "Section + * 2", …), then the brand-new section the user asked for is appended — all in one + * atomic patch. Used once the user confirms the migration prompt. + */ +export function useFirstSectionMigration({ sections }: Params): Result { + const dashboardId = useDashboardStore((s) => s.dashboardId); + const refetch = useDashboardStore((s) => s.refetch); + const [isSaving, setIsSaving] = useState(false); + const { showErrorModal } = useErrorModal(); + + const migrate = useCallback( + async (newSectionTitle: string): Promise => { + const trimmed = newSectionTitle.trim(); + if (!dashboardId || !trimmed) { + return; + } + + const ops: DashboardtypesJSONPatchOperationDTO[] = []; + let counter = 1; + sections.forEach((s) => { + if (!s.title && s.items.length > 0) { + ops.push(titleUntitledSectionOp(s.layoutIndex, `Section ${counter}`)); + counter += 1; + } + }); + ops.push(addSectionOp(trimmed)); + + try { + setIsSaving(true); + await patchDashboardV2({ id: dashboardId }, ops); + refetch(); + } catch (error) { + showErrorModal(error as APIError); + } finally { + setIsSaving(false); + } + }, + [sections, dashboardId, refetch, showErrorModal], + ); + + return { migrate, isSaving }; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/usePersistLayout.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/usePersistLayout.ts new file mode 100644 index 00000000000..5bc902715e1 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/usePersistLayout.ts @@ -0,0 +1,97 @@ +import { useCallback, useState } from 'react'; +import type { Layout } from 'react-grid-layout'; + +import { patchDashboardV2 } from 'api/generated/services/dashboard'; +import { useErrorModal } from 'providers/ErrorModalProvider'; +import APIError from 'types/api/error'; + +import { replaceSectionItemsOp } from '../../../patchOps'; +import { useDashboardStore } from '../../../store/useDashboardStore'; +import type { GridItem } from '../../../utils'; + +interface Params { + layoutIndex: number; + items: GridItem[]; +} + +interface Result { + handleLayoutChange: (rglLayout: Layout[]) => void; + isSaving: boolean; +} + +/** Maps an RGL layout back onto the section's grid items, preserving panel refs. */ +function mergeRglLayout(rglLayout: Layout[], items: GridItem[]): GridItem[] { + const byId = new Map(items.map((item) => [item.id, item])); + return rglLayout + .map((entry) => { + const existing = byId.get(entry.i); + if (!existing) { + return null; + } + return { + ...existing, + x: entry.x, + y: entry.y, + width: entry.w, + height: entry.h, + }; + }) + .filter((item): item is GridItem => item !== null); +} + +function hasGeometryChanged(next: GridItem[], prev: GridItem[]): boolean { + if (next.length !== prev.length) { + return true; + } + const prevById = new Map(prev.map((item) => [item.id, item])); + return next.some((item) => { + const before = prevById.get(item.id); + if (!before) { + return true; + } + return ( + before.x !== item.x || + before.y !== item.y || + before.width !== item.width || + before.height !== item.height + ); + }); +} + +/** + * Persists panel geometry within a single section. Call the returned handler + * from RGL's `onDragStop`/`onResizeStop` (stop events only — not continuous + * `onLayoutChange`) to limit network churn. + */ +export function usePersistLayout({ layoutIndex, items }: Params): Result { + const dashboardId = useDashboardStore((s) => s.dashboardId); + const refetch = useDashboardStore((s) => s.refetch); + const [isSaving, setIsSaving] = useState(false); + const { showErrorModal } = useErrorModal(); + + const handleLayoutChange = useCallback( + async (rglLayout: Layout[]): Promise => { + if (!dashboardId) { + return; + } + const nextItems = mergeRglLayout(rglLayout, items); + if (!hasGeometryChanged(nextItems, items)) { + return; + } + try { + setIsSaving(true); + await patchDashboardV2({ id: dashboardId }, [ + replaceSectionItemsOp(layoutIndex, nextItems), + ]); + refetch(); + } catch (error) { + showErrorModal(error as APIError); + } finally { + setIsSaving(false); + } + }, + [dashboardId, items, layoutIndex, refetch, showErrorModal], + ); + + return { handleLayoutChange, isSaving }; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useRenameSection.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useRenameSection.ts new file mode 100644 index 00000000000..6a9c40a65cc --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useRenameSection.ts @@ -0,0 +1,50 @@ +import { useCallback, useState } from 'react'; + +import { patchDashboardV2 } from 'api/generated/services/dashboard'; +import { useErrorModal } from 'providers/ErrorModalProvider'; +import APIError from 'types/api/error'; + +import { renameSectionOp } from '../../../patchOps'; +import { useDashboardStore } from '../../../store/useDashboardStore'; + +interface Params { + layoutIndex: number; +} + +interface Result { + rename: (title: string) => Promise; + isSaving: boolean; +} + +/** Renames a section's title via `replace /spec/layouts//spec/display/title`. */ +export function useRenameSection({ layoutIndex }: Params): Result { + const dashboardId = useDashboardStore((s) => s.dashboardId); + const refetch = useDashboardStore((s) => s.refetch); + const [isSaving, setIsSaving] = useState(false); + const { showErrorModal } = useErrorModal(); + + const rename = useCallback( + async (title: string): Promise => { + const trimmed = title.trim(); + if (!dashboardId || !trimmed) { + return false; + } + try { + setIsSaving(true); + await patchDashboardV2({ id: dashboardId }, [ + renameSectionOp(layoutIndex, trimmed), + ]); + refetch(); + return true; + } catch (error) { + showErrorModal(error as APIError); + return false; + } finally { + setIsSaving(false); + } + }, + [dashboardId, layoutIndex, refetch, showErrorModal], + ); + + return { rename, isSaving }; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useSectionDragReorder.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useSectionDragReorder.ts new file mode 100644 index 00000000000..156f5ddc2d3 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useSectionDragReorder.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + type DragEndEvent, + type DragStartEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable'; + +import { patchDashboardV2 } from 'api/generated/services/dashboard'; +import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas'; +import { useErrorModal } from 'providers/ErrorModalProvider'; +import APIError from 'types/api/error'; + +import { reorderLayoutsOp } from '../../../patchOps'; +import { useDashboardStore } from '../../../store/useDashboardStore'; +import type { DashboardSection } from '../../../utils'; + +interface Params { + sections: DashboardSection[]; + layouts: DashboardtypesLayoutDTO[] | undefined | null; +} + +interface Result { + sensors: ReturnType; + /** Display order — optimistically reordered on drop so the UI doesn't wait on refetch. */ + orderedSections: DashboardSection[]; + /** The section currently being dragged (for the DragOverlay preview), or null. */ + activeSection: DashboardSection | null; + onDragStart: (event: DragStartEvent) => void; + onDragEnd: (event: DragEndEvent) => void; + onDragCancel: () => void; +} + +/** + * Owns section-reorder drag state. Reorders happen optimistically in local + * state (keyed by stable section id) and persist via a single + * `replace /spec/layouts` patch; the optimistic order is cleared once fresh + * server data arrives. Each section maps 1:1 to a Grid layout via `layoutIndex`, + * so the new layouts array is rebuilt by mapping the reordered sections back. + */ +export function useSectionDragReorder({ sections, layouts }: Params): Result { + const dashboardId = useDashboardStore((s) => s.dashboardId); + const refetch = useDashboardStore((s) => s.refetch); + const [activeId, setActiveId] = useState(null); + const [localOrderIds, setLocalOrderIds] = useState(null); + const { showErrorModal } = useErrorModal(); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + // Server data is the source of truth — drop optimistic order whenever it changes. + useEffect(() => { + setLocalOrderIds(null); + }, [sections]); + + const orderedSections = useMemo(() => { + if (!localOrderIds) { + return sections; + } + const byId = new Map(sections.map((s) => [s.id, s])); + const ordered = localOrderIds + .map((id) => byId.get(id)) + .filter((s): s is DashboardSection => s !== undefined); + return ordered.length === sections.length ? ordered : sections; + }, [sections, localOrderIds]); + + const onDragStart = useCallback((event: DragStartEvent): void => { + setActiveId(String(event.active.id)); + }, []); + + const onDragCancel = useCallback((): void => { + setActiveId(null); + }, []); + + const onDragEnd = useCallback( + async (event: DragEndEvent): Promise => { + setActiveId(null); + const { active, over } = event; + if (!over || active.id === over.id || !dashboardId || !layouts) { + return; + } + + const oldIndex = orderedSections.findIndex((s) => s.id === active.id); + const newIndex = orderedSections.findIndex((s) => s.id === over.id); + if (oldIndex < 0 || newIndex < 0) { + return; + } + + const newOrdered = arrayMove(orderedSections, oldIndex, newIndex); + setLocalOrderIds(newOrdered.map((s) => s.id)); + + const newLayouts = newOrdered + .map((s) => layouts[s.layoutIndex]) + .filter((l): l is DashboardtypesLayoutDTO => l !== undefined); + + try { + await patchDashboardV2({ id: dashboardId }, [reorderLayoutsOp(newLayouts)]); + refetch(); + } catch (error) { + setLocalOrderIds(null); // revert optimistic order on failure + showErrorModal(error as APIError); + } + }, + [orderedSections, layouts, dashboardId, refetch, showErrorModal], + ); + + const activeSection = useMemo( + () => orderedSections.find((s) => s.id === activeId) ?? null, + [orderedSections, activeId], + ); + + return { + sensors, + orderedSections, + activeSection, + onDragStart, + onDragEnd, + onDragCancel, + }; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useToggleSectionCollapse.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useToggleSectionCollapse.ts new file mode 100644 index 00000000000..ce0a48ffb68 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/hooks/useToggleSectionCollapse.ts @@ -0,0 +1,36 @@ +import { useCallback } from 'react'; + +import { + selectIsSectionOpen, + useDashboardStore, +} from '../../../store/useDashboardStore'; + +interface Params { + sectionId: string; +} + +interface Result { + open: boolean; + toggle: () => void; +} + +/** + * Owns a section's expand/collapse state. Collapse is a frontend-only, per-user + * preference (not in the dashboard spec): it lives in the persisted zustand + * store, keyed by dashboardId + section id, and survives reloads. Default open. + */ +export function useToggleSectionCollapse({ sectionId }: Params): Result { + const dashboardId = useDashboardStore((s) => s.dashboardId); + const open = useDashboardStore(selectIsSectionOpen(dashboardId, sectionId)); + const toggleSectionCollapse = useDashboardStore( + (s) => s.toggleSectionCollapse, + ); + + const toggle = useCallback((): void => { + if (dashboardId) { + toggleSectionCollapse(dashboardId, sectionId); + } + }, [dashboardId, sectionId, toggleSectionCollapse]); + + return { open, toggle }; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/index.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/index.tsx index c947fea1428..5d08b1386da 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/index.tsx +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/index.tsx @@ -7,8 +7,11 @@ import type { DashboardtypesPanelDTO, } from 'api/generated/services/sigNoz.schemas'; +import { useDashboardStore } from '../store/useDashboardStore'; import { layoutsToSections } from '../utils'; +import AddSectionControl from './Section/AddSectionControl/AddSectionControl'; import Section from './Section/Section/Section'; +import SectionList from './Section/SectionList'; import styles from './PanelsAndSectionsLayout.module.scss'; import 'react-grid-layout/css/styles.css'; @@ -20,6 +23,8 @@ interface Props { } function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element { + const isEditable = useDashboardStore((s) => s.isEditable); + const sections = useMemo( () => layoutsToSections(layouts, panels), [layouts, panels], @@ -28,6 +33,11 @@ function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element { 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]); + const renderContent = (): ReactNode => { if (isEmpty) { return ( @@ -42,12 +52,27 @@ function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element { ); } + if (isSectioned) { + return ; + } + return sections.map((section) => (
)); }; - return
{renderContent()}
; + return ( +
+ {renderContent()} + {isEditable ? ( + + ) : null} +
+ ); } export default PanelsAndSectionsLayout; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/index.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/index.tsx index 5d9f2182772..edbbdf37463 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/index.tsx +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/index.tsx @@ -1,10 +1,13 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { FullScreen, useFullScreenHandle } from 'react-full-screen'; import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas'; +import useComponentPermission from 'hooks/useComponentPermission'; +import { useAppContext } from 'providers/App/App'; import DashboardDescription from './DashboardDescription'; import PanelsAndSectionsLayout from './PanelsAndSectionsLayout'; +import { useDashboardStore } from './store/useDashboardStore'; import styles from './DashboardContainer.module.scss'; interface Props { @@ -15,6 +18,17 @@ interface Props { function DashboardContainer({ dashboard, refetch }: Props): JSX.Element { const fullScreenHandle = useFullScreenHandle(); + const { user } = useAppContext(); + const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role); + const isEditable = !dashboard.locked && editDashboard; + + // Publish edit context to the store so hooks/components read it from there + // instead of receiving dashboardId/isEditable/refetch as props down the tree. + const setEditContext = useDashboardStore((s) => s.setEditContext); + useEffect(() => { + setEditContext({ dashboardId: dashboard.id ?? '', isEditable, refetch }); + }, [dashboard.id, isEditable, refetch, setEditContext]); + const { spec } = dashboard; const layouts = useMemo(() => spec?.layouts ?? [], [spec?.layouts]); const panels = useMemo(() => spec?.panels ?? {}, [spec?.panels]); diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/patchOps.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/patchOps.ts new file mode 100644 index 00000000000..df2cf5c8fa4 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/patchOps.ts @@ -0,0 +1,177 @@ +import type { + DashboardGridItemDTO, + DashboardtypesJSONPatchOperationDTO, + DashboardtypesLayoutDTO, + DashboardtypesPanelDTO, +} from 'api/generated/services/sigNoz.schemas'; +import { DashboardtypesJSONPatchOperationDTOOp } from 'api/generated/services/sigNoz.schemas'; + +import type { GridItem } from './utils'; + +/** + * Pure RFC-6902 JSON-Patch builders for the V2 dashboard spec. These are + * intentionally side-effect-free (no React, no network) so they can be unit + * tested and reused by the layout hooks. JSON pointers target the postable + * shape: `/spec/layouts/...`, `/spec/panels/...` (matches the existing V2 + * patches in DashboardSettings/General and DashboardDescription). + */ + +const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp; + +const PANEL_REF_PREFIX = '#/spec/panels/'; + +export function panelRef(panelId: string): string { + return `${PANEL_REF_PREFIX}${panelId}`; +} + +/** + * Builds a minimal, backend-valid panel for a given plugin kind. The spec + * requires exactly one query whose plugin kind is allowed for the panel; + * `signoz/BuilderQuery` is allowed for every panel kind and its contents are not + * validated, so an empty builder query is the safe default. The real query is + * filled in once the panel editor lands. + */ +export function createDefaultPanel(pluginKind: string): DashboardtypesPanelDTO { + // The DTO types plugin/query kinds as large generated enum unions; the kind + // here is chosen dynamically by the user, so we build the structurally-valid + // shape and assert the type. + return { + kind: 'Panel', + spec: { + display: { name: 'New panel' }, + plugin: { kind: pluginKind, spec: {} }, + queries: [ + { + kind: 'TimeSeriesQuery', + spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { name: 'A' } } }, + }, + ], + }, + } as unknown as DashboardtypesPanelDTO; +} + +/** Converts a UI grid item back into the spec's grid-item DTO shape. */ +export function gridItemToDTO(item: GridItem): DashboardGridItemDTO { + return { + x: item.x, + y: item.y, + width: item.width, + height: item.height, + content: { $ref: panelRef(item.id) }, + }; +} + +/** Replace the entire items array of one section (used on panel move/resize). */ +export function replaceSectionItemsOp( + layoutIndex: number, + items: GridItem[], +): DashboardtypesJSONPatchOperationDTO { + return { + op: replace, + path: `/spec/layouts/${layoutIndex}/spec/items`, + value: items.map(gridItemToDTO), + }; +} + +/** Replace the whole layouts array (used on section reorder — avoids move-index ambiguity). */ +export function reorderLayoutsOp( + layouts: DashboardtypesLayoutDTO[], +): DashboardtypesJSONPatchOperationDTO { + return { op: replace, path: '/spec/layouts', value: layouts }; +} + +/** An empty titled Grid layout (one section). */ +export function newGridLayout(title: string): DashboardtypesLayoutDTO { + return { + kind: 'Grid' as DashboardtypesLayoutDTO['kind'], + spec: { display: { title }, items: [] }, + }; +} + +/** Append a new, empty titled Grid section. */ +export function addSectionOp( + title: string, +): DashboardtypesJSONPatchOperationDTO { + return { op: add, path: '/spec/layouts/-', value: newGridLayout(title) }; +} + +interface AddPanelToSectionArgs { + panelId: string; + panel: DashboardtypesPanelDTO; + layoutIndex: number; + item: DashboardGridItemDTO; +} + +/** Add a panel to `spec.panels` and an item ref into a section, as one atomic patch. */ +export function addPanelToSectionOps({ + panelId, + panel, + layoutIndex, + item, +}: AddPanelToSectionArgs): DashboardtypesJSONPatchOperationDTO[] { + return [ + { op: add, path: `/spec/panels/${panelId}`, value: panel }, + { op: add, path: `/spec/layouts/${layoutIndex}/spec/items/-`, value: item }, + ]; +} + +interface MovePanelArgs { + sourceIndex: number; + sourceItems: GridItem[]; + targetIndex: number; + targetItems: GridItem[]; +} + +/** Move a panel's item ref from one section to another (panel stays in spec.panels). */ +export function movePanelBetweenSectionsOps({ + sourceIndex, + sourceItems, + targetIndex, + targetItems, +}: MovePanelArgs): DashboardtypesJSONPatchOperationDTO[] { + return [ + replaceSectionItemsOp(sourceIndex, sourceItems), + replaceSectionItemsOp(targetIndex, targetItems), + ]; +} + +/** Rename an existing section's title. */ +export function renameSectionOp( + layoutIndex: number, + title: string, +): DashboardtypesJSONPatchOperationDTO { + return { + op: replace, + path: `/spec/layouts/${layoutIndex}/spec/display/title`, + value: title, + }; +} + +/** + * First-section migration: give an existing untitled (free-flowing) layout a + * title, turning it into a section in place while preserving its panels. + */ +export function titleUntitledSectionOp( + layoutIndex: number, + title: string, +): DashboardtypesJSONPatchOperationDTO { + return { + op: add, + path: `/spec/layouts/${layoutIndex}/spec/display`, + value: { title }, + }; +} + +/** Remove a section. Panel cleanup (orphaned refs) is handled by the caller. */ +export function removeSectionOp( + layoutIndex: number, +): DashboardtypesJSONPatchOperationDTO { + return { op: remove, path: `/spec/layouts/${layoutIndex}` }; +} + +/** Remove a panel definition from `spec.panels`. */ +export function removePanelOp( + panelId: string, +): DashboardtypesJSONPatchOperationDTO { + return { op: remove, path: `/spec/panels/${panelId}` }; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/store/slices/collapseSlice.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/store/slices/collapseSlice.ts new file mode 100644 index 00000000000..14622023506 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/store/slices/collapseSlice.ts @@ -0,0 +1,35 @@ +import type { StateCreator } from 'zustand'; + +import type { DashboardStore } from '../useDashboardStore'; + +/** + * Section collapse state — frontend-only and persisted to localStorage. Keyed by + * dashboardId → section stable id → open. An absent entry means "open" (the + * default). This is intentionally NOT server state: collapse is a per-user UI + * preference, so it lives here instead of in the dashboard spec. + */ +export interface CollapseSlice { + collapsed: Record>; + toggleSectionCollapse: (dashboardId: string, sectionId: string) => void; +} + +export const createCollapseSlice: StateCreator< + DashboardStore, + [['zustand/persist', unknown]], + [], + CollapseSlice +> = (set, get) => ({ + collapsed: {}, + toggleSectionCollapse: (dashboardId, sectionId): void => { + const { collapsed } = get(); + const current = collapsed[dashboardId]?.[sectionId]; + // Absent → open by default, so the first toggle closes it. + const next = current === undefined ? false : !current; + set({ + collapsed: { + ...collapsed, + [dashboardId]: { ...collapsed[dashboardId], [sectionId]: next }, + }, + }); + }, +}); diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/store/slices/editContextSlice.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/store/slices/editContextSlice.ts new file mode 100644 index 00000000000..e062d02e3ed --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/store/slices/editContextSlice.ts @@ -0,0 +1,38 @@ +import type { StateCreator } from 'zustand'; + +import type { DashboardStore } from '../useDashboardStore'; + +/** + * Edit context shared across the V2 dashboard tree — the dashboard id, whether + * the user can edit, and the react-query refetch. Set once by DashboardContainer + * so hooks/components read it from the store instead of receiving it as props + * through every layer. Not persisted. + */ +export interface EditContextSlice { + dashboardId: string; + isEditable: boolean; + refetch: () => void; + setEditContext: (ctx: { + dashboardId: string; + isEditable: boolean; + refetch: () => void; + }) => void; +} + +export const createEditContextSlice: StateCreator< + DashboardStore, + [['zustand/persist', unknown]], + [], + EditContextSlice +> = (set) => ({ + dashboardId: '', + isEditable: false, + refetch: (): void => undefined, + setEditContext: (ctx): void => { + set({ + dashboardId: ctx.dashboardId, + isEditable: ctx.isEditable, + refetch: ctx.refetch, + }); + }, +}); diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/store/useDashboardStore.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/store/useDashboardStore.ts new file mode 100644 index 00000000000..d38c47bf236 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/store/useDashboardStore.ts @@ -0,0 +1,44 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import { + createEditContextSlice, + type EditContextSlice, +} from './slices/editContextSlice'; +import { + createCollapseSlice, + type CollapseSlice, +} from './slices/collapseSlice'; + +export type DashboardStore = EditContextSlice & CollapseSlice; + +/** + * V2 dashboard session store. Holds cross-cutting client state only — never the + * dashboard spec (that stays in react-query via useGetDashboardV2). Two slices: + * - edit-context: dashboardId / isEditable / refetch (set once, not persisted). + * - collapse: per-section open state (frontend-only, persisted to localStorage). + */ +export const useDashboardStore = create()( + persist( + (...a) => ({ + ...createEditContextSlice(...a), + ...createCollapseSlice(...a), + }), + { + name: '@signoz/dashboard-v2', + // Persist only the collapse map — context (incl. the refetch fn) is transient. + partialize: (state) => ({ collapsed: state.collapsed }), + }, + ), +); + +/** Selector: is a section open? Absent entry (or no dashboard) → open by default. */ +export const selectIsSectionOpen = + (dashboardId: string, sectionId: string) => + (state: DashboardStore): boolean => { + if (!dashboardId) { + return true; + } + const value = state.collapsed[dashboardId]?.[sectionId]; + return value === undefined ? true : value; + }; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/utils.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/utils.ts index 7224203ac06..47a0fa5cf47 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/utils.ts +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/utils.ts @@ -72,7 +72,6 @@ export interface DashboardSection { /** Position of this section's Grid in `spec.layouts`. All JSON-Patch ops target by this. */ layoutIndex: number; title: string | undefined; - open: boolean; items: GridItem[]; repeatVariable: string | undefined; } @@ -127,15 +126,11 @@ export function layoutsToSections( .filter((it): it is GridItem => it !== null); const title = spec?.display?.title; - // `open` defaults to true when no collapse field is set (the section - // is expanded by default). - const open = spec?.display?.collapse?.open !== false; return { id: getSectionStableId(items, idx), layoutIndex: idx, title, - open, items, repeatVariable: spec?.repeatVariable, };