Compare commits

..

2 Commits

Author SHA1 Message Date
Vinícius Lourenço
f050419cff perf(tsconfig): enable incremental & fix large type resolution 2026-07-01 10:28:20 -03:00
Vinícius Lourenço
5ea514b94f chore(tsconfig): explicit path mappings and cleaned includes 2026-07-01 10:28:20 -03:00
36 changed files with 324 additions and 1753 deletions

View File

@@ -1,5 +1,14 @@
import { MutableRefObject } from 'react';
import { Chart, ChartConfiguration, ChartData, Color } from 'chart.js';
import {
ActiveElement,
Chart,
ChartConfiguration,
ChartData,
ChartEvent,
ChartType,
Color,
TooltipItem,
} from 'chart.js';
import * as chartjsAdapter from 'chartjs-adapter-date-fns';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -60,184 +69,189 @@ export const getGraphOptions = (
minTime?: number,
maxTime?: number,
// eslint-disable-next-line sonarjs/cognitive-complexity
): CustomChartOptions => ({
animation: {
duration: animate ? 200 : 0,
},
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: true,
},
plugins: {
...(staticLine
? {
annotation: {
annotations: [
{
type: 'line',
yMin: staticLine.yMin,
yMax: staticLine.yMax,
borderColor: staticLine.borderColor,
borderWidth: staticLine.borderWidth,
label: {
content: staticLine.lineText,
enabled: true,
font: {
size: 10,
): CustomChartOptions =>
({
animation: {
duration: animate ? 200 : 0,
},
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: true,
},
plugins: {
...(staticLine
? {
annotation: {
annotations: [
{
type: 'line',
yMin: staticLine.yMin,
yMax: staticLine.yMax,
borderColor: staticLine.borderColor,
borderWidth: staticLine.borderWidth,
label: {
content: staticLine.lineText,
enabled: true,
font: {
size: 10,
},
borderWidth: 0,
position: 'start',
backgroundColor: 'transparent',
color: staticLine.textColor,
},
borderWidth: 0,
position: 'start',
backgroundColor: 'transparent',
color: staticLine.textColor,
},
},
],
],
},
}
: {}),
title: {
display: title !== undefined,
text: title,
},
legend: {
display: false,
},
tooltip: {
callbacks: {
title(context: TooltipItem<'line'>[]): string | string[] {
const date = dayjs(context[0].parsed.x);
return date
.tz(timezone.value)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_FULL_SECONDS);
},
label(context: TooltipItem<'line'>): string | string[] {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += getToolTipValue(context.parsed.y.toString(), yAxisUnit);
}
return label;
},
labelTextColor(labelData: TooltipItem<'line'>): Color {
if (labelData.datasetIndex === nearestDatasetIndex.current) {
return 'rgba(255, 255, 255, 1)';
}
return 'rgba(255, 255, 255, 0.75)';
},
}
: {}),
title: {
display: title !== undefined,
text: title,
},
legend: {
display: false,
},
tooltip: {
callbacks: {
title(context): string | string[] {
const date = dayjs(context[0].parsed.x);
return date
.tz(timezone.value)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_FULL_SECONDS);
},
label(context): string | string[] {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += getToolTipValue(context.parsed.y.toString(), yAxisUnit);
}
return label;
},
labelTextColor(labelData): Color {
if (labelData.datasetIndex === nearestDatasetIndex.current) {
return 'rgba(255, 255, 255, 1)';
}
return 'rgba(255, 255, 255, 0.75)';
position: 'custom',
itemSort(item1: TooltipItem<'line'>, item2: TooltipItem<'line'>): number {
return item2.parsed.y - item1.parsed.y;
},
},
position: 'custom',
itemSort(item1, item2): number {
return item2.parsed.y - item1.parsed.y;
},
[dragSelectPluginId]: createDragSelectPluginOptions(
!!onDragSelect,
onDragSelect,
dragSelectColor,
),
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
!!onDragSelect,
currentTheme === 'dark' ? 'white' : 'black',
),
},
[dragSelectPluginId]: createDragSelectPluginOptions(
!!onDragSelect,
onDragSelect,
dragSelectColor,
),
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
!!onDragSelect,
currentTheme === 'dark' ? 'white' : 'black',
),
},
layout: {
padding: 0,
},
scales: {
x: {
stacked: isStacked,
offset: false,
grid: {
layout: {
padding: 0,
},
scales: {
x: {
stacked: isStacked,
offset: false,
grid: {
display: true,
color: getGridColor(),
drawTicks: true,
},
adapters: {
date: chartjsAdapter,
},
time: {
unit: xAxisTimeUnit?.unitName || 'minute',
stepSize: xAxisTimeUnit?.stepSize || 1,
displayFormats: {
millisecond: DATE_TIME_FORMATS.TIME_SECONDS,
second: DATE_TIME_FORMATS.TIME_SECONDS,
minute: DATE_TIME_FORMATS.TIME,
hour: DATE_TIME_FORMATS.SLASH_SHORT,
day: DATE_TIME_FORMATS.DATE_SHORT,
week: DATE_TIME_FORMATS.DATE_SHORT,
month: DATE_TIME_FORMATS.YEAR_MONTH,
year: DATE_TIME_FORMATS.YEAR_SHORT,
},
},
type: 'time',
ticks: { color: getAxisLabelColor(currentTheme) },
...(minTime && {
min: dayjs(minTime).tz(timezone.value).format(),
}),
...(maxTime && {
max: dayjs(maxTime).tz(timezone.value).format(),
}),
},
y: {
stacked: isStacked,
display: true,
color: getGridColor(),
drawTicks: true,
},
adapters: {
date: chartjsAdapter,
},
time: {
unit: xAxisTimeUnit?.unitName || 'minute',
stepSize: xAxisTimeUnit?.stepSize || 1,
displayFormats: {
millisecond: DATE_TIME_FORMATS.TIME_SECONDS,
second: DATE_TIME_FORMATS.TIME_SECONDS,
minute: DATE_TIME_FORMATS.TIME,
hour: DATE_TIME_FORMATS.SLASH_SHORT,
day: DATE_TIME_FORMATS.DATE_SHORT,
week: DATE_TIME_FORMATS.DATE_SHORT,
month: DATE_TIME_FORMATS.YEAR_MONTH,
year: DATE_TIME_FORMATS.YEAR_SHORT,
grid: {
display: true,
color: getGridColor(),
},
},
type: 'time',
ticks: { color: getAxisLabelColor(currentTheme) },
...(minTime && {
min: dayjs(minTime).tz(timezone.value).format(),
}),
...(maxTime && {
max: dayjs(maxTime).tz(timezone.value).format(),
}),
},
y: {
stacked: isStacked,
display: true,
grid: {
display: true,
color: getGridColor(),
},
ticks: {
color: getAxisLabelColor(currentTheme),
// Include a dollar sign in the ticks
callback(value): string {
return getYAxisFormattedValue(value.toString(), yAxisUnit);
ticks: {
color: getAxisLabelColor(currentTheme),
// Include a dollar sign in the ticks
callback(value: number | string): string {
return getYAxisFormattedValue(value.toString(), yAxisUnit);
},
},
},
},
},
elements: {
line: {
tension: 0,
cubicInterpolationMode: 'monotone',
},
point: {
hoverBackgroundColor: (ctx: any): string => {
if (ctx?.element?.options?.borderColor) {
return ctx.element.options.borderColor;
}
return 'rgba(0,0,0,0.1)';
elements: {
line: {
tension: 0,
cubicInterpolationMode: 'monotone',
},
hoverRadius: 5,
},
},
onClick: (event, element, chart): void => {
if (onClickHandler) {
onClickHandler(event, element, chart, data);
}
},
onHover: (event, _, chart): void => {
if (event.native) {
const interactions = chart.getElementsAtEventForMode(
event.native,
'nearest',
{
intersect: false,
point: {
hoverBackgroundColor: (ctx: any): string => {
if (ctx?.element?.options?.borderColor) {
return ctx.element.options.borderColor;
}
return 'rgba(0,0,0,0.1)';
},
true,
);
if (interactions[0]) {
nearestDatasetIndex.current = interactions[0].datasetIndex;
hoverRadius: 5,
},
},
onClick: (
event: ChartEvent,
element: ActiveElement[],
chart: Chart,
): void => {
if (onClickHandler) {
onClickHandler(event, element, chart, data);
}
}
},
});
},
onHover: (event: ChartEvent, _: ActiveElement[], chart: Chart): void => {
if (event.native) {
const interactions = chart.getElementsAtEventForMode(
event.native,
'nearest',
{
intersect: false,
},
true,
);
if (interactions[0]) {
nearestDatasetIndex.current = interactions[0].datasetIndex;
}
}
},
}) as CustomChartOptions;
declare module 'chart.js' {
interface TooltipPositionerMap {

View File

@@ -3,6 +3,7 @@ import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { ResizeTable } from 'components/ResizeTable';
import { useNotifications } from 'hooks/useNotifications';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
@@ -10,7 +11,6 @@ import {
selectIsDashboardLocked,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { toast } from '@signozhq/ui/sonner';
import { getChartManagerColumns } from './getChartMangerColumns';
import { ExtendedChartDataset, getDefaultTableDataSet } from './utils';
@@ -44,6 +44,7 @@ export default function ChartManager({
decimalPrecision = PrecisionOptionsEnum.TWO,
onCancel,
}: ChartManagerProps): JSX.Element {
const { notifications } = useNotifications();
const { legendItemsMap } = useLegendsSync({
config,
subscribeToFocusChange: false,
@@ -135,9 +136,11 @@ export default function ChartManager({
const handleSave = useCallback((): void => {
syncSeriesVisibilityToLocalStorage();
toast.success('The updated graphs & legends are saved');
notifications.success({
message: 'The updated graphs & legends are saved',
});
onCancel?.();
}, [syncSeriesVisibilityToLocalStorage, onCancel]);
}, [syncSeriesVisibilityToLocalStorage, notifications, onCancel]);
return (
<div className="chart-manager-container">

View File

@@ -5,7 +5,7 @@ import { render, screen } from 'tests/test-utils';
import ChartManager from '../ChartManager';
const mockSyncSeriesVisibilityToLocalStorage = jest.fn();
const mockToastSuccess = jest.fn();
const mockNotificationsSuccess = jest.fn();
jest.mock('lib/uPlotV2/context/PlotContext', () => ({
usePlotContext: (): {
@@ -46,11 +46,12 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
}): boolean => s.dashboardData?.locked ?? false,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
},
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): { notifications: { success: jest.Mock } } => ({
notifications: {
success: mockNotificationsSuccess,
},
}),
}));
jest.mock('components/ResizeTable', () => {
@@ -159,7 +160,7 @@ describe('ChartManager', () => {
expect(screen.queryByTestId('row-2')).not.toBeInTheDocument();
});
it('calls syncSeriesVisibilityToLocalStorage, toast.success, and onCancel when Save is clicked', async () => {
it('calls syncSeriesVisibilityToLocalStorage, notifications.success, and onCancel when Save is clicked', async () => {
render(
<ChartManager
config={createMockConfig() as UPlotConfigBuilder}
@@ -171,9 +172,9 @@ describe('ChartManager', () => {
await userEvent.click(screen.getByRole('button', { name: /Save/ }));
expect(mockSyncSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
expect(mockToastSuccess).toHaveBeenCalledWith(
'The updated graphs & legends are saved',
);
expect(mockNotificationsSuccess).toHaveBeenCalledWith({
message: 'The updated graphs & legends are saved',
});
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
});

View File

@@ -5,14 +5,6 @@
height: 100%;
flex-direction: column;
// Stacked children (the FullView / standalone graph-manager) sit below the chart
// in the same container; size the chart region to its content so they aren't
// pushed out. Only this case opts out of filling the height — the dashboard grid,
// alert preview, and other charts keep 100% so they fill their container.
&--with-layout-children {
height: auto;
}
&--legend-right {
flex-direction: row;
}

View File

@@ -63,7 +63,6 @@ export default function ChartLayout({
className={cx('chart-layout', {
'chart-layout--legend-right':
legendConfig.position === LegendPosition.RIGHT,
'chart-layout--with-layout-children': !!layoutChildren,
})}
>
<div className="chart-layout__content">

View File

@@ -1,12 +1,13 @@
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import { EQueryType } from 'types/common/dashboard';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import styles from './PanelTypeSwitcher.module.scss';
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
import { getPanelTypeDisabledReason } from './utils';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
@@ -30,7 +31,22 @@ function PanelTypeSwitcher({
signal,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = usePanelTypeSelectItems({ queryType, signal });
const items = PANEL_TYPES.map(({ panelKind, label, Icon }) => {
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
const disabledReason = getPanelTypeDisabledReason({
kind: panelKind,
queryType: queryType ?? EQueryType.QUERY_BUILDER,
signal,
label,
});
return {
value: panelKind,
label,
icon: <Icon size={14} />,
disabled: !!disabledReason,
tooltip: disabledReason,
};
});
return (
<div className={styles.field}>

View File

@@ -1,48 +0,0 @@
import { useMemo } from 'react';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
import { getPanelTypeDisabledReason } from './utils';
interface UsePanelTypeSelectItemsArgs {
/** Active query type — a kind that can't be authored in it is disabled (defaults to Query Builder). */
queryType?: EQueryType;
/** Current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
}
/**
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
* tooltip) when the active query type or signal is incompatible — resolved through
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
* modal's header so the two selectors apply the same rule and can't drift.
*/
export function usePanelTypeSelectItems({
queryType,
signal,
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
return useMemo(
() =>
PANEL_TYPES.map(({ panelKind, label, Icon }) => {
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
const disabledReason = getPanelTypeDisabledReason({
kind: panelKind,
queryType: queryType ?? EQueryType.QUERY_BUILDER,
signal,
label,
});
return {
value: panelKind,
label,
icon: <Icon size={14} />,
disabled: !!disabledReason,
tooltip: disabledReason,
};
}),
[queryType, signal],
);
}

View File

@@ -164,7 +164,7 @@ function PanelEditorQueryBuilder({
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
<RunQueryBtn
className="run-query-dashboard-btn"
label="Run Query"
label="Stage & Run Query"
onStageRunQuery={onStageRunQuery}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={onCancelQuery}

View File

@@ -49,13 +49,6 @@
background: var(--l2-background);
}
// Standalone View stacks the graph-manager below the chart inside the surface (it
// must stay within the chart's PlotContext). Let it flow out of the surface so the
// modal body scrolls as a whole, instead of clipping it or scrolling the panel.
.surfaceStacked {
overflow: visible;
}
.state {
flex: 1;
display: flex;

View File

@@ -1,13 +1,11 @@
import { useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import type {
PanelPagination,
@@ -32,14 +30,6 @@ interface PreviewPaneProps {
onDragSelect: (start: number, end: number) => void;
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
pagination?: PanelPagination;
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
panelMode?: PanelMode;
/** Hide the preview's top row entirely (query-type badge + time picker) — the View modal has its own header. */
hideHeader?: boolean;
/** Dashboard-wide preferences (cursor sync, …) forwarded to the body; the modal isolates cursor-sync. */
dashboardPreference?: DashboardPreference;
/** Close the standalone View modal — forwarded to the time-series/bar graph manager. */
onCloseStandaloneView?: () => void;
}
/**
@@ -57,10 +47,6 @@ function PreviewPane({
refetch,
onDragSelect,
pagination,
panelMode = PanelMode.DASHBOARD_EDIT,
hideHeader = false,
dashboardPreference,
onCloseStandaloneView,
}: PreviewPaneProps): JSX.Element {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const queryType = getPanelQueryType(panel);
@@ -72,24 +58,18 @@ function PreviewPane({
return (
<div className={styles.preview}>
{!hideHeader && (
<div className={styles.header}>
<PlotTag
queryType={queryType}
panelType={panelType}
className={styles.queryType}
/>
<div className={styles.dateTimeSelector}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<div className={styles.header}>
<PlotTag
queryType={queryType}
panelType={panelType}
className={styles.queryType}
/>
<div className={styles.dateTimeSelector}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
)}
</div>
<div className={styles.container}>
<div
className={cx(styles.surface, {
[styles.surfaceStacked]: panelMode === PanelMode.STANDALONE_VIEW,
})}
>
<div className={styles.surface}>
<PanelHeader
panelId={panelId}
panel={panel}
@@ -110,11 +90,9 @@ function PreviewPane({
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
panelMode={panelMode}
dashboardPreference={dashboardPreference}
panelMode={PanelMode.DASHBOARD_EDIT}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onCloseStandaloneView={onCloseStandaloneView}
/>
</div>
</div>

View File

@@ -1,268 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import PanelEditorContainer from '../index';
/**
* Characterization test for the editor's composition: which derived values and
* options it forwards to the draft/query/query-sync/type-switch hooks and to its
* children. The leaf hooks are mocked as arg-capturing spies so this pins the
* wiring; it stays valid (and guards behavior) after that wiring is pulled into a
* shared edit-session hook, since the mocks intercept the leaf hooks either way.
*/
const mockSetSpec = jest.fn();
const mockRefetch = jest.fn();
const mockCancelQuery = jest.fn();
const mockBuildSaveSpec = jest.fn((spec: unknown) => spec);
const mockOnChangePanelKind = jest.fn();
const mockSave = jest.fn().mockResolvedValue(undefined);
const mockUseDraft = jest.fn();
jest.mock('../hooks/usePanelEditorDraft', () => ({
usePanelEditorDraft: (panel: unknown): unknown => mockUseDraft(panel),
}));
const mockUseQuery = jest.fn();
jest.mock('../../hooks/usePanelQuery', () => ({
usePanelQuery: (args: unknown): unknown => mockUseQuery(args),
}));
const mockUseQuerySync = jest.fn();
jest.mock('../hooks/usePanelEditorQuerySync', () => ({
usePanelEditorQuerySync: (args: unknown): unknown => mockUseQuerySync(args),
}));
const mockUseTypeSwitch = jest.fn();
jest.mock('../hooks/usePanelTypeSwitch', () => ({
usePanelTypeSwitch: (args: unknown): unknown => mockUseTypeSwitch(args),
}));
jest.mock('../hooks/usePanelEditorSave', () => ({
usePanelEditorSave: (): unknown => ({ save: mockSave, isSaving: false }),
}));
jest.mock('../hooks/useSwitchColumnsOnSignalChange', () => ({
useSwitchColumnsOnSignalChange: jest.fn(),
}));
jest.mock('../hooks/useSeedNewListColumns', () => ({
useSeedNewListColumns: jest.fn(),
}));
jest.mock('../hooks/useLegendSeries', () => ({
useLegendSeries: (): [] => [],
}));
jest.mock('../hooks/useTableColumns', () => ({
useTableColumns: (): [] => [],
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({ currentQuery: { queryType: 'builder' } }),
}));
jest.mock(
'../../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions',
() => ({
usePanelInteractions: (): unknown => ({
onDragSelect: jest.fn(),
dashboardPreference: {},
}),
}),
);
jest.mock('@signozhq/ui/resizable', () => ({
__esModule: true,
ResizablePanelGroup: ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <div>{children}</div>,
ResizablePanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
ResizableHandle: (): null => null,
useDefaultLayout: (): unknown => ({
defaultLayout: undefined,
onLayoutChanged: jest.fn(),
}),
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
// Children mocked to capture props (and expose a Save trigger / footer slot).
const mockHeaderProps = jest.fn();
jest.mock('../Header/Header', () => ({
__esModule: true,
default: (props: { onSave: () => void }): JSX.Element => {
mockHeaderProps(props);
return (
<button type="button" data-testid="editor-save" onClick={props.onSave}>
save
</button>
);
},
}));
const mockPreviewProps = jest.fn();
jest.mock('../PreviewPane/PreviewPane', () => ({
__esModule: true,
default: (props: unknown): JSX.Element => {
mockPreviewProps(props);
return <div data-testid="preview" />;
},
}));
const mockQbProps = jest.fn();
jest.mock('../PanelEditorQueryBuilder/PanelEditorQueryBuilder', () => ({
__esModule: true,
default: (props: { footer?: React.ReactNode }): JSX.Element => {
mockQbProps(props);
return <div data-testid="qb">{props.footer}</div>;
},
}));
const mockConfigProps = jest.fn();
jest.mock('../ConfigPane/ConfigPane', () => ({
__esModule: true,
default: (props: unknown): JSX.Element => {
mockConfigProps(props);
return <div data-testid="config" />;
},
}));
jest.mock('../ListColumnsEditor/ListColumnsEditor', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="list-columns" />,
}));
function makePanel(kind: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind, spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
const baseProps = {
dashboardId: 'dash-1',
panelId: 'panel-1',
onClose: jest.fn(),
onSaved: jest.fn(),
};
function setup(
panel: DashboardtypesPanelDTO,
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
): void {
mockUseDraft.mockReturnValue({
draft: panel,
spec: panel.spec,
setSpec: mockSetSpec,
isSpecDirty: false,
});
mockUseQuery.mockReturnValue({
data: { response: undefined },
isFetching: false,
error: null,
cancelQuery: mockCancelQuery,
refetch: mockRefetch,
pagination: undefined,
});
mockUseQuerySync.mockReturnValue({
runQuery: jest.fn(),
isQueryDirty: false,
buildSaveSpec: mockBuildSaveSpec,
});
mockUseTypeSwitch.mockReturnValue({
onChangePanelKind: mockOnChangePanelKind,
});
render(<PanelEditorContainer {...baseProps} panel={panel} {...overrides} />);
}
describe('PanelEditorContainer composition', () => {
beforeEach(() => jest.clearAllMocks());
it('renders the editor shell with preview, query builder, and config pane', () => {
const panel = makePanel('signoz/TimeSeriesPanel');
setup(panel);
expect(screen.getByTestId('panel-editor-v2')).toBeInTheDocument();
expect(screen.getByTestId('preview')).toBeInTheDocument();
expect(screen.getByTestId('qb')).toBeInTheDocument();
expect(screen.getByTestId('config')).toBeInTheDocument();
expect(mockPreviewProps).toHaveBeenCalledWith(
expect.objectContaining({
panel,
panelDefinition: getPanelDefinition('signoz/TimeSeriesPanel'),
}),
);
expect(mockQbProps).toHaveBeenCalledWith(
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
);
expect(mockConfigProps).toHaveBeenCalledWith(
expect.objectContaining({
panel,
spec: panel.spec,
onChangePanelKind: mockOnChangePanelKind,
}),
);
});
it('forwards the derived panel type + query-sync options to the leaf hooks', () => {
const panel = makePanel('signoz/TimeSeriesPanel');
setup(panel);
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({ panel, panelId: 'panel-1', enabled: true }),
);
expect(mockUseQuerySync).toHaveBeenCalledWith(
expect.objectContaining({
panelType: PANEL_TYPES.TIME_SERIES,
setSpec: mockSetSpec,
refetch: mockRefetch,
alwaysSerializeQuery: false,
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
}),
);
expect(mockUseTypeSwitch).toHaveBeenCalledWith(
expect.objectContaining({
panelType: PANEL_TYPES.TIME_SERIES,
spec: panel.spec,
setSpec: mockSetSpec,
}),
);
});
it('marks a new panel dirty and always serializes its query', () => {
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
expect(mockUseQuerySync).toHaveBeenCalledWith(
expect.objectContaining({ alwaysSerializeQuery: true }),
);
expect(mockHeaderProps).toHaveBeenCalledWith(
expect.objectContaining({ isDirty: true }),
);
});
it('bakes the live query into the spec on save, then notifies', async () => {
const panel = makePanel('signoz/TimeSeriesPanel');
setup(panel, { onSaved: baseProps.onSaved });
await userEvent.click(screen.getByTestId('editor-save'));
await waitFor(() => expect(baseProps.onSaved).toHaveBeenCalled());
expect(mockBuildSaveSpec).toHaveBeenCalledWith(panel.spec);
expect(mockSave).toHaveBeenCalledWith(panel.spec);
});
it('renders the list-columns editor only for list panels', () => {
setup(makePanel('signoz/ListPanel'));
expect(screen.getByTestId('list-columns')).toBeInTheDocument();
});
it('omits the list-columns editor for non-list panels', () => {
setup(makePanel('signoz/TimeSeriesPanel'));
expect(screen.queryByTestId('list-columns')).not.toBeInTheDocument();
});
});

View File

@@ -1,119 +0,0 @@
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import {
usePanelQuery,
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import { usePanelEditorDraft } from './usePanelEditorDraft';
import { usePanelEditorQuerySync } from './usePanelEditorQuerySync';
import { usePanelTypeSwitch } from './usePanelTypeSwitch';
interface UsePanelEditSessionArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/** Per-view time window (epoch ms); omit to follow the dashboard's global window. */
time?: PanelQueryTimeOverride;
/** Serialize the live builder query into the spec on save even if unchanged (new panels). */
alwaysSerializeQuery?: boolean;
/** Seed an empty builder with the kind's default signal (new panels) — off for drilldown. */
seedQuerySignal?: boolean;
}
export interface UsePanelEditSessionApi {
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
draft: DashboardtypesPanelDTO;
spec: DashboardtypesPanelSpecDTO;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
isSpecDirty: boolean;
/** Restore the draft to the originally-loaded panel. */
reset: () => void;
/** Draft kind → V1 panel type (drives the query builder + preview). */
panelType: PANEL_TYPES;
panelDefinition: RenderablePanelDefinition;
/** The kind's first supported signal — seeds new queries/columns. */
defaultSignal: TelemetrytypesSignalDTO;
/** Shared query result for the draft over the resolved time window. */
query: UsePanelQueryResult;
/** Stage & run the live builder query into the draft. */
runQuery: () => void;
isQueryDirty: boolean;
/** Bake the live (possibly un-run) query into a spec — for save / editor handoff. */
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
/** Switch the draft's visualization kind in place (reversible per session). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* The panel-editing pipeline shared by the full-page editor and the View modal's
* drilldown editor: a local draft, its query result over the resolved time window,
* the staged-query sync, and the visualization-kind switch. Each consumer layers its
* own concerns on top (the editor adds save + list seeding; the modal adds per-view
* time isolation + reset). Keeping the wiring here stops the two from drifting.
*/
export function usePanelEditSession({
panel,
panelId,
time,
alwaysSerializeQuery = false,
seedQuerySignal = false,
}: UsePanelEditSessionArgs): UsePanelEditSessionApi {
const { draft, spec, setSpec, isSpecDirty, reset } =
usePanelEditorDraft(panel);
const fullKind = draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(fullKind);
const panelType = PANEL_KIND_TO_PANEL_TYPE[fullKind];
const defaultSignal = panelDefinition.supportedSignals[0];
const query = usePanelQuery({
panel: draft,
panelId,
time,
enabled: !!panelDefinition,
});
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch: query.refetch,
alwaysSerializeQuery,
signal: seedQuerySignal ? defaultSignal : undefined,
});
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draft.spec,
panelType,
setSpec,
});
return {
draft,
spec,
setSpec,
isSpecDirty,
reset,
panelType,
panelDefinition,
defaultSignal,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
};
}

View File

@@ -10,7 +10,13 @@ import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { getExecStats } from '../queryV5/v5ResponseData';
@@ -21,8 +27,11 @@ import layoutStorage from './layoutStorage';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
import { usePanelEditSession } from './hooks/usePanelEditSession';
import { usePanelQuery } from '../hooks/usePanelQuery';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useTableColumns } from './hooks/useTableColumns';
@@ -58,28 +67,7 @@ function PanelEditorContainer({
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
// panel always serializes its seed query and seeds the builder's default signal.
const {
draft,
spec,
setSpec,
isSpecDirty,
panelDefinition,
defaultSignal,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
} = usePanelEditSession({
panel,
panelId,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
});
const { data, isFetching, error, cancelQuery, refetch, pagination } = query;
const { draft, spec, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
// Live query type (the selected tab) — the type switcher disables kinds that can't be
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
// query until staged, so the spec would lag the tab.
@@ -103,7 +91,37 @@ function PanelEditorContainer({
storage: layoutStorage,
});
// Panel kind → V1 panel type, which drives the query builder and preview.
const fullKind = draft.spec.plugin.kind;
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
PANEL_TYPES.TIME_SERIES;
// One shared query result for the whole editor; the preview renders it.
const panelDefinition = getPanelDefinition(draft.spec.plugin.kind);
const { data, isFetching, error, cancelQuery, refetch, pagination } =
usePanelQuery({
panel: draft,
panelId,
enabled: !!panelDefinition,
});
// A new panel's default signal (its kind's first supported) — seeds the query and columns.
const defaultSignal = panelDefinition.supportedSignals[0];
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch,
// New panel's seed query is the builder default, not a real saved query —
// always serialize it on save.
alwaysSerializeQuery: isNew,
signal: defaultSignal,
});
// Switch the panel's visualization kind in place (reversible per session).
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
// Spec and query dirtiness are tracked independently so query re-serialization
// never false-dirties. A new panel is always savable (you're creating it).

View File

@@ -1,10 +0,0 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
/**
* Router location state for opening the panel editor pre-loaded with edits instead of
* the saved panel. The View modal sets this so "Switch to Edit Mode" carries its
* drilldown-edited spec (queries/plugin) into the editor.
*/
export interface PanelEditorHandoffState {
editSpec?: DashboardtypesPanelSpecDTO;
}

View File

@@ -1,9 +1,7 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
@@ -39,7 +37,6 @@ function BarPanelRenderer({
onDragSelect,
dashboardPreference,
panelMode,
onCloseStandaloneView,
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
@@ -117,32 +114,6 @@ function BarPanelRenderer({
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
// The standalone View modal shows V1's graph-manager legend below the chart:
// Filter Series + per-series show/hide + Save. Series visibility auto-persists to
// localStorage (STANDALONE_VIEW selection prefs), keyed by panelId.
const layoutChildren = useMemo(
() =>
panelMode === PanelMode.STANDALONE_VIEW ? (
<div className={PanelStyles.chartManagerContainer}>
<ChartManager
config={config}
alignedData={chartData}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
onCancel={onCloseStandaloneView}
/>
</div>
) : null,
[
panelMode,
config,
chartData,
spec.formatting?.unit,
decimalPrecision,
onCloseStandaloneView,
],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
@@ -176,7 +147,6 @@ function BarPanelRenderer({
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
layoutChildren={layoutChildren}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}

View File

@@ -1,9 +1,7 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
@@ -39,7 +37,6 @@ function TimeSeriesPanelRenderer({
onDragSelect,
dashboardPreference,
panelMode,
onCloseStandaloneView,
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
@@ -118,32 +115,6 @@ function TimeSeriesPanelRenderer({
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
// The standalone View modal shows V1's graph-manager legend below the chart:
// Filter Series + per-series show/hide + Save. Series visibility auto-persists to
// localStorage (STANDALONE_VIEW selection prefs), keyed by panelId.
const layoutChildren = useMemo(
() =>
panelMode === PanelMode.STANDALONE_VIEW ? (
<div className={PanelStyles.chartManagerContainer}>
<ChartManager
config={config}
alignedData={chartData}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
onCancel={onCloseStandaloneView}
/>
</div>
) : null,
[
panelMode,
config,
chartData,
spec.formatting?.unit,
decimalPrecision,
onCloseStandaloneView,
],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
@@ -177,7 +148,6 @@ function TimeSeriesPanelRenderer({
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
layoutChildren={layoutChildren}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}

View File

@@ -7,7 +7,3 @@
height: 100%;
position: relative;
}
.chartManagerContainer {
padding: 36px 0;
}

View File

@@ -22,9 +22,6 @@ export type PanelClickEvent =
type DragSelect = (start: number, end: number) => void;
/** Close the standalone View modal — fired by the chart's graph-manager Save/Cancel. */
type CloseStandaloneView = () => void;
/**
* Per-kind interaction props — each kind exposes only the gestures it supports.
* Keyed by `PanelKind`; `PanelRendererProps<K>` indexes this, so a missing kind
@@ -34,12 +31,10 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
'signoz/TimeSeriesPanel': {
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
};
'signoz/BarChartPanel': {
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
};
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
@@ -55,5 +50,4 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
export interface AnyPanelInteractionProps {
onClick?: (event: PanelClickEvent) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
}

View File

@@ -14,19 +14,6 @@ jest.mock(
}),
);
const mockOpenView = jest.fn();
jest.mock('../../hooks/useViewPanel', () => ({
useViewPanel: (): {
openView: jest.Mock;
closeView: jest.Mock;
expandedPanelId: string | null;
} => ({
openView: mockOpenView,
closeView: jest.fn(),
expandedPanelId: null,
}),
}));
const mockMovePanel = jest.fn();
jest.mock('../../hooks/useMovePanelToSection', () => ({
useMovePanelToSection: (): jest.Mock => mockMovePanel,
@@ -277,13 +264,18 @@ describe('usePanelActionItems', () => {
});
});
it('view opens the View modal for the panel', () => {
it('not-yet-implemented actions (view) fire the placeholder alert with the feature name', () => {
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const view = result.current.items.find(
(i) => 'key' in i && i.key === 'view-panel',
);
(view as { onClick: () => void }).onClick();
expect(mockOpenView).toHaveBeenCalledWith('panel-1');
expect(alertSpy).toHaveBeenCalledTimes(1);
expect(alertSpy).toHaveBeenCalledWith('View option clicked');
alertSpy.mockRestore();
});
it('create-alert seeds an alert from this panel', () => {

View File

@@ -30,7 +30,6 @@ import {
type MovePanelArgs,
useMovePanelToSection,
} from '../hooks/useMovePanelToSection';
import { useViewPanel } from '../hooks/useViewPanel';
import { PANEL_ACTION_META } from './panelActionMeta';
// Stable fallback so renders without layout context don't churn the mutation
@@ -147,7 +146,6 @@ export function usePanelActionItems({
const isEditable = useDashboardStore((s) => s.isEditable);
const openPanelEditor = useOpenPanelEditor();
const createAlert = useCreateAlertFromPanel();
const { openView } = useViewPanel();
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
// supplies data (`sections`), so no callbacks are threaded through it.
@@ -180,7 +178,7 @@ export function usePanelActionItems({
key: 'view-panel',
label: 'View',
icon: <Fullscreen size={14} />,
onClick: (): void => openView(panelId),
onClick: (): void => notImplementedYet('View'),
});
}
if (isEditable && canEditWidget && panelCapabilities.edit) {
@@ -265,7 +263,6 @@ export function usePanelActionItems({
panelActions,
sections,
panelId,
openView,
openPanelEditor,
createAlert,
movePanel,

View File

@@ -32,8 +32,6 @@ interface PanelBodyProps {
searchTerm?: string;
/** Server-side paging handles — only consumed by raw/list renderers. */
pagination?: PanelPagination;
/** Close the standalone View modal — only consumed by the time-series/bar graph manager. */
onCloseStandaloneView?: () => void;
}
/**
@@ -53,7 +51,6 @@ function PanelBody({
panelMode = PanelMode.DASHBOARD_VIEW,
searchTerm,
pagination,
onCloseStandaloneView,
}: PanelBodyProps): JSX.Element {
// react-query keeps the previous response during refetches, so its presence is
// the "have something to show" signal — only fail hard when there's nothing.
@@ -115,7 +112,6 @@ function PanelBody({
dashboardPreference={dashboardPreference}
searchTerm={searchTerm}
pagination={pagination}
onCloseStandaloneView={onCloseStandaloneView}
/>
</div>
);

View File

@@ -1,17 +1,9 @@
// Expanded state: a compact input that fits the header row.
.input {
width: min(100%, 320px);
height: 24px;
width: 180px;
}
.clear {
--button-height: 18px;
--button-width: 18px;
--button-padding: 0;
}
.searchTrigger {
--button-width: 24px;
--button-height: 24px;
--button-padding: 4px;
}

View File

@@ -43,7 +43,6 @@ function PanelHeaderSearch({
color="secondary"
size="icon"
onClick={(): void => setExpanded(true)}
className={styles.searchTrigger}
data-testid="panel-header-search-trigger"
aria-label="Search"
>

View File

@@ -1,52 +0,0 @@
@use '../../../../../../styles/scrollbar' as *;
.modal {
:global(.ant-modal-body) {
padding: 0px;
}
}
// Tall, fixed-height column so the renderer's resize observer measures real
// dimensions — the chart self-sizes to fill whatever space it's given.
.content {
display: flex;
flex-direction: column;
gap: 8px;
height: 78vh;
overflow: auto;
padding: 12px;
@include custom-scrollbar;
}
.queryBuilder {
flex: 0 0 auto;
overflow: auto;
display: flex;
flex-direction: column;
}
.toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex: 0 0 auto;
}
.toolbarTime {
display: flex;
align-items: center;
gap: 4px;
}
.body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 480px;
}
.panelTypeSelector {
width: 240px;
}

View File

@@ -1,49 +0,0 @@
import { Modal } from 'antd';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ViewPanelModalContent from './ViewPanelModalContent';
import styles from './ViewPanelModal.module.scss';
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface ViewPanelModalProps {
/**
* The expanded panel and its id. Absent while the modal is closed — a single
* host instance lives at the layout level and only carries a panel when open.
*/
panel?: DashboardtypesPanelDTO;
panelId?: string;
open: boolean;
onClose: () => void;
}
function ViewPanelModal({
panel,
panelId,
open,
onClose,
}: ViewPanelModalProps): JSX.Element {
const name = panel?.spec.display.name ?? '';
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
centered
width="85%"
destroyOnClose
className={styles.modal}
title={
<TooltipSimple title={name} arrow>
<span className={styles.title}>{name} - (View mode)</span>
</TooltipSimple>
}
>
{open && panel && panelId && (
<ViewPanelModalContent panel={panel} panelId={panelId} onClose={onClose} />
)}
</Modal>
);
}
export default ViewPanelModal;

View File

@@ -1,126 +0,0 @@
import { useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import PanelEditorQueryBuilder from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
import { usePanelInteractions } from '../hooks/usePanelInteractions';
import ViewPanelModalHeader from './ViewPanelModalHeader';
import { useViewPanelEditor } from './useViewPanelEditor';
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
import styles from './ViewPanelModal.module.scss';
interface ViewPanelModalContentProps {
panel: DashboardtypesPanelDTO;
panelId: string;
/** Close the modal — wired to the graph manager's Save/Cancel. */
onClose: () => void;
}
/**
* Body of the View modal: a compact drilldown editor. It renders an editable draft of
* the panel (preview) over a per-view time window plus the shared query builder, so the
* user can tweak + Stage & Run without touching the dashboard. Edits are temporary.
*/
function ViewPanelModalContent({
panel,
panelId,
onClose,
}: ViewPanelModalContentProps): JSX.Element | null {
const {
timeOverride,
selectedInterval,
onTimeChange,
refreshWindow,
onDragSelect,
} = useViewPanelTimeWindow();
const {
draft,
panelDefinition,
signal,
defaultSignal,
queryType,
query,
runQuery,
onChangePanelKind,
resetQuery,
buildSaveSpec,
} = useViewPanelEditor({ panel, panelId, time: timeOverride });
const { data, isFetching, error, refetch, cancelQuery, pagination } = query;
// Drag-to-zoom stays inside the modal; opt the chart out of the dashboard's
// cursor-sync group so a drag here can't replay onto the grid panels.
const { dashboardPreference } = usePanelInteractions();
const isolatedPreference = useMemo<DashboardPreference>(
() => ({ ...dashboardPreference, syncMode: DashboardCursorSync.None }),
[dashboardPreference],
);
const openPanelEditor = useOpenPanelEditor();
// The View action only appears for registered kinds, so this is defensive.
if (!panelDefinition) {
return null;
}
return (
<div className={styles.content} data-testid="view-panel-modal-content">
<ViewPanelModalHeader
selectedInterval={selectedInterval}
startMs={timeOverride.startMs}
endMs={timeOverride.endMs}
onTimeChange={onTimeChange}
isFetching={isFetching}
onRefresh={(): void => {
// Relative windows re-anchor to now (new key → refetch); a fixed
// custom window just re-runs the same query.
if (selectedInterval === 'custom') {
refetch();
} else {
refreshWindow();
}
}}
onSwitchToEdit={(): void =>
// Carry the drilldown edits so the editor opens on them, not the saved panel.
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) })
}
panelKind={draft.spec.plugin.kind}
queryType={queryType}
signal={signal}
onChangePanelKind={onChangePanelKind}
onResetQuery={resetQuery}
/>
<div className={styles.queryBuilder}>
<PanelEditorQueryBuilder
panelKind={draft.spec.plugin.kind}
signal={signal ?? defaultSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
/>
</div>
<div className={styles.body}>
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
panelMode={PanelMode.STANDALONE_VIEW}
dashboardPreference={isolatedPreference}
onCloseStandaloneView={onClose}
hideHeader
/>
</div>
</div>
);
}
export default ViewPanelModalContent;

View File

@@ -1,119 +0,0 @@
import { PenLine, RotateCw } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import type {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { usePanelTypeSelectItems } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems';
import ConfigSelect from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/controls/ConfigSelect/ConfigSelect';
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import type { EQueryType } from 'types/common/dashboard';
import styles from './ViewPanelModal.module.scss';
interface ViewPanelModalHeaderProps {
selectedInterval: Time | CustomTimeType;
/** Current window bounds (epoch ms) — seed the picker's modal display. */
startMs: number;
endMs: number;
onTimeChange: (
interval: Time | CustomTimeType,
range?: [number, number],
) => void;
/** Any query in flight — spins the refresh icon and disables it. */
isFetching: boolean;
onRefresh: () => void;
onSwitchToEdit: () => void;
/** Draft's current kind (selected value of the panel-type selector). */
panelKind: PanelKind;
/** Active query type — disables kinds that can't be authored in it (e.g. List under PromQL). */
queryType?: EQueryType;
/** Current builder datasource — disables types that don't support it. */
signal?: TelemetrytypesSignalDTO;
onChangePanelKind: (kind: PanelKind) => void;
/** Restore the saved query + kind (drilldown reset). */
onResetQuery: () => void;
}
/**
* Toolbar for the View modal: reset the drilldown, open the full editor, switch the
* visualization kind, pick a per-view time window (isolated from the dashboard), and
* refresh. Mirrors V1's FullView header controls.
*/
function ViewPanelModalHeader({
selectedInterval,
startMs,
endMs,
onTimeChange,
isFetching,
onRefresh,
onSwitchToEdit,
panelKind,
queryType,
signal,
onChangePanelKind,
onResetQuery,
}: ViewPanelModalHeaderProps): JSX.Element {
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
return (
<div className={styles.toolbar}>
<div className={styles.panelTypeSelector}>
<ConfigSelect<PanelKind>
testId="view-panel-type-selector"
value={panelKind}
items={panelTypeItems}
onChange={onChangePanelKind}
/>
</div>
<Button
variant="outlined"
color="secondary"
prefix={<PenLine />}
onClick={onSwitchToEdit}
data-testid="view-panel-switch-to-edit"
>
Switch to Edit Mode
</Button>
<Button
variant="link"
color="primary"
onClick={onResetQuery}
data-testid="view-panel-reset-query"
>
Reset Query
</Button>
<div className={styles.toolbarTime}>
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
isModalTimeSelection
disableUrlSync
onTimeChange={onTimeChange}
modalSelectedInterval={selectedInterval as Time}
modalInitialStartTime={startMs}
modalInitialEndTime={endMs}
/>
<Button
size="icon"
variant="solid"
color="primary"
onClick={onRefresh}
disabled={isFetching}
aria-label="Refresh"
data-testid="view-panel-refresh"
>
<RotateCw className={cx({ 'animate-spin': isFetching })} />
</Button>
</div>
</div>
);
}
export default ViewPanelModalHeader;

View File

@@ -1,113 +0,0 @@
import { useCallback, useMemo } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { usePanelEditSession } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/hooks/usePanelEditSession';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import {
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import type { EQueryType } from 'types/common/dashboard';
interface UseViewPanelEditorArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/** Per-view time window (epoch ms); isolates the preview from the dashboard. */
time: PanelQueryTimeOverride;
}
export interface UseViewPanelEditorApi {
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
draft: DashboardtypesPanelDTO;
/** Resolved renderer for the draft's current kind. */
panelDefinition: RenderablePanelDefinition | undefined;
/** Current builder datasource — drives the panel-type selector's disabled rule. */
signal?: TelemetrytypesSignalDTO;
/** The kind's first supported signal — the query builder's fallback datasource. */
defaultSignal: TelemetrytypesSignalDTO;
/** Active query type (selected builder tab) — drives the panel-type selector's disabled rule. */
queryType: EQueryType;
/** Query result for the draft over the per-view window. */
query: UsePanelQueryResult;
/** Stage & run the live builder query into the draft (drilldown; not persisted). */
runQuery: () => void;
/** Switch the draft's visualization kind (temporary; reversible per session). */
onChangePanelKind: (kind: PanelKind) => void;
/** Restore the saved panel's query + kind, discarding the drilldown edits. */
resetQuery: () => void;
/** Bake the live (possibly un-run) query into a spec — used to hand edits to the full editor. */
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
}
/**
* Turns the View modal into a compact, drilldown panel editor on top of the shared
* `usePanelEditSession`: the same draft/query/query-sync/type-switch pipeline the
* full editor uses, scoped to a per-view time window, plus drilldown-only extras
* (the saved-query snapshot for Reset, and the builder signal for the type selector).
* Edits are temporary — they live in the builder/URL and the draft, never the
* dashboard, matching V1.
*/
export function useViewPanelEditor({
panel,
panelId,
time,
}: UseViewPanelEditorArgs): UseViewPanelEditorApi {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const {
draft,
panelDefinition,
defaultSignal,
query,
runQuery,
onChangePanelKind,
buildSaveSpec,
reset,
} = usePanelEditSession({ panel, panelId, time });
// The saved panel's query, captured once — the restore target for Reset Query.
const savedQuery = useMemo(
() =>
fromPerses(
panel.spec.queries,
PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
),
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
[],
);
const resetQuery = useCallback((): void => {
// Draft back to the saved panel (query + kind); builder back to the saved query.
reset();
redirectWithQueryBuilderData(savedQuery);
}, [reset, redirectWithQueryBuilderData, savedQuery]);
// Current builder datasource for the panel-type disabled rule — resolved the same
// way as the full editor's ConfigPane so the two selectors stay in sync.
const signal = resolveSignal(draft.spec.queries, defaultSignal);
return {
draft,
panelDefinition,
signal,
defaultSignal,
queryType: currentQuery.queryType,
query,
runQuery,
onChangePanelKind,
resetQuery,
buildSaveSpec,
};
}

View File

@@ -1,108 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
import { useSelector } from 'react-redux';
import type {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import GetMinMax from 'lib/getMinMax';
import type { PanelQueryTimeOverride } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
const NS_PER_MS = 1e6;
export interface ViewPanelTimeWindow {
/** Absolute window (epoch ms) to pass to usePanelQuery as a time override. */
timeOverride: PanelQueryTimeOverride;
/** Interval shown in the picker — a relative `Time` or `'custom'`. */
selectedInterval: Time | CustomTimeType;
/** Apply a selection from DateTimeSelectionV2 (modal mode). */
onTimeChange: (
interval: Time | CustomTimeType,
range?: [number, number],
) => void;
/** Re-anchor a relative window to "now" (manual refresh); no-op for custom. */
refreshWindow: () => void;
/** Drag-to-zoom on a time chart → set a custom window locally (not the dashboard's). */
onDragSelect: (start: number, end: number) => void;
}
/**
* Per-view time window for the panel View modal, isolated from the dashboard's
* global time (V1 parity: the modal's time selector doesn't move the grid). Seeded
* once from the current global window, then owned locally. Relative intervals
* resolve to an absolute ms window via the same `GetMinMax` the app-wide picker uses.
*/
export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
const { selectedTime, minTime, maxTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const [selectedInterval, setSelectedInterval] = useState<
Time | CustomTimeType
>(selectedTime as Time);
const [timeOverride, setTimeOverride] = useState<PanelQueryTimeOverride>(
() => ({
startMs: Math.floor(minTime / NS_PER_MS),
endMs: Math.floor(maxTime / NS_PER_MS),
}),
);
const onTimeChange = useCallback(
(interval: Time | CustomTimeType, range?: [number, number]): void => {
setSelectedInterval(interval);
// Absolute range comes through directly (already epoch ms).
if (interval === 'custom' && range) {
setTimeOverride({
startMs: Math.floor(range[0]),
endMs: Math.floor(range[1]),
});
return;
}
// GetMinMax returns nanoseconds — convert to the ms window we work in.
const { minTime: startNs, maxTime: endNs } = GetMinMax(interval);
setTimeOverride({
startMs: Math.floor(startNs / NS_PER_MS),
endMs: Math.floor(endNs / NS_PER_MS),
});
},
[],
);
const refreshWindow = useCallback((): void => {
// A custom window is fixed; only relative intervals re-anchor to now.
if (selectedInterval === 'custom') {
return;
}
const { minTime: startNs, maxTime: endNs } = GetMinMax(selectedInterval);
setTimeOverride({
startMs: Math.floor(startNs / NS_PER_MS),
endMs: Math.floor(endNs / NS_PER_MS),
});
}, [selectedInterval]);
const onDragSelect = useCallback((start: number, end: number): void => {
// Drag values are already epoch ms (same as the global custom range).
const startMs = Math.floor(start);
const endMs = Math.floor(end);
// Ignore a click / zero-width or inverted selection.
if (startMs >= endMs) {
return;
}
setSelectedInterval('custom');
setTimeOverride({ startMs, endMs });
}, []);
return useMemo(
() => ({
timeOverride,
selectedInterval,
onTimeChange,
refreshWindow,
onDragSelect,
}),
[timeOverride, selectedInterval, onTimeChange, refreshWindow, onDragSelect],
);
}

View File

@@ -1,178 +0,0 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
// The preview reuses the edit page's PreviewPane (chart + header + heavy render
// path); stub it (capturing props) so this suite asserts the modal shell + what it
// threads down, not the preview internals (PreviewPane/PanelHeader own those).
const mockPreviewPaneRender = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane',
() =>
function MockPreviewPane(props: Record<string, unknown>): ReactElement {
mockPreviewPaneRender(props);
return <div data-testid="preview-pane" />;
},
);
// Isolate from the draft/query-builder plumbing (its own suite covers it).
jest.mock('../ViewPanelModal/useViewPanelEditor', () => ({
useViewPanelEditor: (args: {
panel: { spec: { plugin: { kind: string } } };
}): unknown => {
const { kind } = args.panel.spec.plugin;
return {
draft: args.panel,
panelDefinition: {
kind,
actions: { search: kind === 'signoz/ListPanel' },
Renderer: (): null => null,
},
query: {
data: { response: undefined, requestPayload: undefined, legendMap: {} },
isLoading: false,
isFetching: false,
error: null,
refetch: jest.fn(),
cancelQuery: jest.fn(),
pagination: undefined,
},
runQuery: jest.fn(),
onChangePanelKind: jest.fn(),
resetQuery: jest.fn(),
signal: undefined,
defaultSignal: 'logs',
buildSaveSpec: (spec: unknown): unknown => spec,
};
},
}));
// The View modal reuses the edit page's query builder, which reads the global
// QueryBuilder context and pulls in the ClickHouse/PromQL editors; stub it here.
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder',
() =>
function MockPanelEditorQueryBuilder(): ReactElement {
return <div data-testid="panel-editor-v2-query-builder" />;
},
);
jest.mock('../hooks/usePanelInteractions', () => ({
usePanelInteractions: (): unknown => ({
onDragSelect: jest.fn(),
dashboardPreference: { syncMode: 0 },
}),
}));
// The header mounts DateTimeSelectionV2 (redux + router + heavy deps); stub it so
// this suite asserts the modal body, not the toolbar internals.
jest.mock(
'../ViewPanelModal/ViewPanelModalHeader',
() =>
function MockViewPanelModalHeader(): ReactElement {
return <div data-testid="view-panel-header" />;
},
);
jest.mock('../ViewPanelModal/useViewPanelTimeWindow', () => ({
useViewPanelTimeWindow: (): unknown => ({
timeOverride: { startMs: 0, endMs: 0 },
selectedInterval: '5m',
onTimeChange: jest.fn(),
refreshWindow: jest.fn(),
onDragSelect: jest.fn(),
}),
}));
const mockOpenEditor = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
() => ({
useOpenPanelEditor: (): jest.Mock => mockOpenEditor,
}),
);
const renderWithProvider = (ui: ReactElement): ReturnType<typeof render> =>
render(<TooltipProvider>{ui}</TooltipProvider>);
function makePanel(kind: string, name = 'My panel'): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name },
plugin: { kind, spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('ViewPanelModal', () => {
it('renders nothing until opened', () => {
renderWithProvider(
<ViewPanelModal
panel={makePanel('signoz/TimeSeriesPanel')}
panelId="p1"
open={false}
onClose={jest.fn()}
/>,
);
expect(
screen.queryByTestId('view-panel-modal-content'),
).not.toBeInTheDocument();
});
it('renders the header, query builder, and preview when open', () => {
renderWithProvider(
<ViewPanelModal
panel={makePanel('signoz/TimeSeriesPanel', 'CPU usage')}
panelId="p1"
open
onClose={jest.fn()}
/>,
);
expect(screen.getByTestId('view-panel-modal-content')).toBeInTheDocument();
expect(screen.getByTestId('view-panel-header')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-query-builder'),
).toBeInTheDocument();
expect(screen.getByTestId('preview-pane')).toBeInTheDocument();
});
it('invokes onClose when the modal is dismissed', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
renderWithProvider(
<ViewPanelModal
panel={makePanel('signoz/TimeSeriesPanel')}
panelId="p1"
open
onClose={onClose}
/>,
);
await user.click(screen.getByLabelText('Close'));
expect(onClose).toHaveBeenCalled();
});
// Charts share one global cursor-sync key and uPlot replays drag across the
// group; the modal must opt out so a drag here can't move the dashboard's time.
it('opts the chart out of the dashboard cursor-sync group', () => {
mockPreviewPaneRender.mockClear();
renderWithProvider(
<ViewPanelModal
panel={makePanel('signoz/TimeSeriesPanel')}
panelId="p1"
open
onClose={jest.fn()}
/>,
);
const props = mockPreviewPaneRender.mock.calls.at(-1)?.[0] as {
dashboardPreference?: { syncMode?: unknown };
};
expect(props.dashboardPreference?.syncMode).toBe(DashboardCursorSync.None);
});
});

View File

@@ -1,93 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import GetMinMax from 'lib/getMinMax';
import { useViewPanelTimeWindow } from '../ViewPanelModal/useViewPanelTimeWindow';
const NS_PER_MS = 1e6;
// Global time is stored in nanoseconds; the hook must surface milliseconds.
const mockState = {
globalTime: {
selectedTime: '6h',
minTime: 6_000_000 * NS_PER_MS,
maxTime: 7_000_000 * NS_PER_MS,
},
};
jest.mock('react-redux', () => ({
useSelector: (selector: (s: unknown) => unknown): unknown =>
selector(mockState),
}));
jest.mock('lib/getMinMax', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockGetMinMax = GetMinMax as unknown as jest.Mock;
describe('useViewPanelTimeWindow', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('seeds the window from global time, converting ns → ms', () => {
const { result } = renderHook(() => useViewPanelTimeWindow());
expect(result.current.timeOverride).toStrictEqual({
startMs: mockState.globalTime.minTime / NS_PER_MS,
endMs: mockState.globalTime.maxTime / NS_PER_MS,
});
expect(result.current.selectedInterval).toBe('6h');
});
it('converts GetMinMax (ns) to ms on a relative selection', () => {
mockGetMinMax.mockReturnValue({
minTime: 1_700_000_000_000 * NS_PER_MS,
maxTime: 1_700_000_300_000 * NS_PER_MS,
});
const { result } = renderHook(() => useViewPanelTimeWindow());
act(() => result.current.onTimeChange('5m'));
expect(result.current.selectedInterval).toBe('5m');
expect(result.current.timeOverride).toStrictEqual({
startMs: 1_700_000_000_000,
endMs: 1_700_000_300_000,
});
});
it('uses an absolute custom range as-is (already ms)', () => {
const { result } = renderHook(() => useViewPanelTimeWindow());
act(() => result.current.onTimeChange('custom', [111, 222]));
expect(mockGetMinMax).not.toHaveBeenCalled();
expect(result.current.timeOverride).toStrictEqual({
startMs: 111,
endMs: 222,
});
});
it('sets a custom window from a drag selection (modal-local, ms)', () => {
const { result } = renderHook(() => useViewPanelTimeWindow());
act(() => result.current.onDragSelect(1000, 5000));
expect(result.current.selectedInterval).toBe('custom');
expect(result.current.timeOverride).toStrictEqual({
startMs: 1000,
endMs: 5000,
});
});
it('ignores a zero-width or inverted drag selection', () => {
const { result } = renderHook(() => useViewPanelTimeWindow());
const initial = result.current.timeOverride;
act(() => result.current.onDragSelect(5000, 5000));
act(() => result.current.onDragSelect(9000, 1000));
expect(result.current.timeOverride).toStrictEqual(initial);
});
});

View File

@@ -1,50 +0,0 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
export interface UseViewPanelApi {
/** Panel id currently expanded in the View modal; null when none is open. */
expandedPanelId: string | null;
/** Open the View modal for a panel by writing its id to the URL. */
openView: (panelId: string) => void;
/** Close the View modal by clearing the URL param. */
closeView: () => void;
}
/**
* Drives the panel View modal off the `expandedWidgetId` URL param (V1 parity):
* the open state is shareable, survives refresh, and the browser back-button
* closes it. Reuses V1's param key so a deep-linked V1 URL maps cleanly.
*/
export function useViewPanel(): UseViewPanelApi {
const { safeNavigate } = useSafeNavigate();
const { pathname } = useLocation();
const urlQuery = useUrlQuery();
const expandedPanelId = urlQuery.get(QueryParams.expandedWidgetId);
const openView = useCallback(
(panelId: string): void => {
// Copy before mutating: useUrlQuery returns a memoized instance.
const next = new URLSearchParams(urlQuery);
next.set(QueryParams.expandedWidgetId, panelId);
safeNavigate(`${pathname}?${next.toString()}`);
},
[pathname, safeNavigate, urlQuery],
);
const closeView = useCallback((): void => {
const next = new URLSearchParams(urlQuery);
next.delete(QueryParams.expandedWidgetId);
// Drop the drilldown editor's URL state so it doesn't leak to the dashboard
// (the in-modal query builder writes compositeQuery, V1 parity).
next.delete(QueryParams.compositeQuery);
next.delete(QueryParams.graphType);
const search = next.toString();
safeNavigate(search ? `${pathname}?${search}` : pathname);
}, [pathname, safeNavigate, urlQuery]);
return { expandedPanelId, openView, closeView };
}

View File

@@ -8,8 +8,6 @@ import type {
import { useDashboardStore } from '../store/useDashboardStore';
import { layoutsToSections } from '../utils';
import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
import { useViewPanel } from './Panel/hooks/useViewPanel';
import ViewPanelModal from './Panel/ViewPanelModal/ViewPanelModal';
import Section from './Section/Section/Section';
import SectionList from './Section/SectionList';
import styles from './PanelsAndSectionsLayout.module.scss';
@@ -28,12 +26,6 @@ function PanelsAndSectionsLayout({
}: PanelsAndSectionsLayoutProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
// Single View-modal host for the whole dashboard, driven by the URL
// (`expandedWidgetId`). One mounted modal beats one-per-panel: no N location
// subscriptions, and the expanded panel is looked up by id from the map.
const { expandedPanelId, closeView } = useViewPanel();
const expandedPanel = expandedPanelId ? panels[expandedPanelId] : undefined;
const sections = useMemo(
() => layoutsToSections(layouts, panels),
[layouts, panels],
@@ -64,17 +56,7 @@ function PanelsAndSectionsLayout({
));
};
return (
<div className={styles.body}>
{renderContent()}
<ViewPanelModal
open={!!expandedPanel}
panel={expandedPanel}
panelId={expandedPanelId ?? undefined}
onClose={closeView}
/>
</div>
);
return <div className={styles.body}>{renderContent()}</div>;
}
export default PanelsAndSectionsLayout;

View File

@@ -3,28 +3,21 @@ import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
import { useDashboardStore } from '../store/useDashboardStore';
/**
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
* caller can open the editor with just the panel id. The optional `state` is passed as router
* location state — the View modal uses it to hand off its drilldown-edited spec so the editor
* opens on those edits rather than the saved panel.
* caller can open the editor with just the panel id.
*/
export function useOpenPanelEditor(): (
panelId: string,
state?: PanelEditorHandoffState,
) => void {
export function useOpenPanelEditor(): (panelId: string) => void {
const { safeNavigate } = useSafeNavigate();
const dashboardId = useDashboardStore((s) => s.dashboardId);
return useCallback(
(panelId: string, state?: PanelEditorHandoffState): void => {
(panelId: string): void => {
safeNavigate(
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
state ? { state } : undefined,
);
},
[safeNavigate, dashboardId],

View File

@@ -16,7 +16,6 @@ import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { buildDefaultPluginSpec } from '../DashboardContainer/Panels/utils/buildDefaultPluginSpec';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
import type { PanelEditorHandoffState } from '../DashboardContainer/PanelEditor/panelEditorHandoff';
import {
parseNewPanelKind,
parseNewPanelLayoutIndex,
@@ -33,13 +32,9 @@ function PanelEditorPage(): JSX.Element {
dashboardId: string;
panelId: string;
}>();
const { search, state } = useLocation();
const { search } = useLocation();
const { safeNavigate } = useSafeNavigate();
// Edits handed off from the View modal's drilldown — open the editor on these
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
const handoffSpec = (state as PanelEditorHandoffState | null)?.editSpec;
const { data, isLoading, isError, error } = useGetDashboardV2({
id: dashboardId,
});
@@ -49,20 +44,17 @@ function PanelEditorPage(): JSX.Element {
// kind rather than looking one up. Persisted (with a real id) only on save.
const newKind = parseNewPanelKind(panelId, search);
const existingPanel = dashboard?.spec.panels[panelId];
const panel = useMemo(() => {
if (newKind) {
return createDefaultPanel(
newKind,
buildDefaultPluginSpec(getPanelDefinition(newKind)?.sections ?? []),
buildDefaultQueries(newKind),
);
}
if (!existingPanel) {
return undefined;
}
// Open on the modal's drilldown edits when handed off; else the saved panel.
return handoffSpec ? { ...existingPanel, spec: handoffSpec } : existingPanel;
}, [newKind, existingPanel, handoffSpec]);
const panel = useMemo(
() =>
newKind
? createDefaultPanel(
newKind,
buildDefaultPluginSpec(getPanelDefinition(newKind)?.sections ?? []),
buildDefaultQueries(newKind),
)
: existingPanel,
[newKind, existingPanel],
);
// Target section for a newly-created panel (set by the "Add panel" trigger).
const layoutIndex = parseNewPanelLayoutIndex(search);

View File

@@ -23,10 +23,9 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"incremental": true,
"tsBuildInfoFile": "./node_modules/.cache/ts/tsconfig.tsbuildinfo",
"paths": {
"*": [
"./src/*"
],
"@constants/*": [
"./src/container/OnboardingContainer/constants/*"
],
@@ -35,7 +34,32 @@
],
"test-mocks/*": [
"./__mocks__/*"
]
],
"api": ["./src/api"],
"AppRoutes": ["./src/AppRoutes"],
"ReactI18": ["./src/ReactI18"],
"store": ["./src/store"],
"styles.scss": ["./src/styles.scss"],
"api/*": ["./src/api/*"],
"AppRoutes/*": ["./src/AppRoutes/*"],
"assets/*": ["./src/assets/*"],
"components/*": ["./src/components/*"],
"constants/*": ["./src/constants/*"],
"container/*": ["./src/container/*"],
"hooks/*": ["./src/hooks/*"],
"lib/*": ["./src/lib/*"],
"mocks-server/*": ["./src/mocks-server/*"],
"modules/*": ["./src/modules/*"],
"pages/*": ["./src/pages/*"],
"parser/*": ["./src/parser/*"],
"periscope/*": ["./src/periscope/*"],
"providers/*": ["./src/providers/*"],
"schemas/*": ["./src/schemas/*"],
"store/*": ["./src/store/*"],
"__tests__/*": ["./src/__tests__/*"],
"tests/*": ["./src/tests/*"],
"types/*": ["./src/types/*"],
"utils/*": ["./src/utils/*"]
},
"plugins": [
{
@@ -52,18 +76,11 @@
],
"include": [
"./src",
"./src/**/*.ts",
"src/**/*.tsx",
"src/**/*.d.ts",
"babel.config.cjs",
"./jest.config.ts",
"./__mocks__",
"./conf/default.conf",
"./public",
"./commitlint.config.ts",
"./vite.config.ts",
"./babel.config.cjs",
"./jest.config.ts",
"./jest.setup.ts",
"./tests/**.ts",
"./**/*.d.ts"
"./vite.config.ts",
"./commitlint.config.ts"
]
}