mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-30 03:40:43 +01:00
Compare commits
7 Commits
issue_5519
...
feat/panel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2f347d3f4 | ||
|
|
f3eda0956a | ||
|
|
d31255e717 | ||
|
|
34c90b1289 | ||
|
|
4bb32a69e5 | ||
|
|
8eb299e8fa | ||
|
|
5f39cd0038 |
@@ -20,6 +20,8 @@ interface ConfigPaneProps {
|
||||
/** The panel spec — the single editing surface (title/description + section slices). */
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Switch the panel to another visualization kind. */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/** Panel's resolved series, provided to sections that need them (legend colors). */
|
||||
legendSeries: LegendSeries[];
|
||||
/** Table panel's resolved value columns, for the table-only editors. */
|
||||
@@ -36,6 +38,7 @@ function ConfigPane({
|
||||
panelKind,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
onChangePanelKind,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
@@ -95,6 +98,8 @@ function ConfigPane({
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getPanelDefinition } from '../../../Panels/registry';
|
||||
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';
|
||||
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
panelKind: PanelKind;
|
||||
/** Panel's current datasource — drives the disabled rule. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
onChange: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization-type selector (rendered inside the Visualization section). Types whose
|
||||
* supported signals exclude the panel's current datasource are disabled (V1 parity —
|
||||
* e.g. List needs logs/traces, not metrics). The datasource is unknown for
|
||||
* PromQL/ClickHouse queries, in which case no type is disabled.
|
||||
*/
|
||||
function PanelTypeSwitcher({
|
||||
panelKind,
|
||||
signal,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
const items = PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
const definition = getPanelDefinition(panelKind as PanelKind);
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
icon: <Icon size={14} />,
|
||||
disabled:
|
||||
!!signal && !!definition && !definition.supportedSignals.includes(signal),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel Type</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-type-switcher"
|
||||
value={panelKind}
|
||||
items={items}
|
||||
onChange={(value): void => onChange(value as PanelKind)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelTypeSwitcher;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelTypeSwitcher from '../PanelTypeSwitcher';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
|
||||
function openDropdown(): void {
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
}
|
||||
|
||||
describe('PanelTypeSwitcher', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// List supports only logs/traces; every other kind also supports metrics.
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => ({
|
||||
supportedSignals:
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
: ['metrics', 'logs', 'traces'],
|
||||
}));
|
||||
});
|
||||
|
||||
it('fires onChange with the chosen plugin kind', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PanelTypeSwitcher panelKind="signoz/TimeSeriesPanel" onChange={onChange} />,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
fireEvent.click(screen.getByText('List'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
|
||||
});
|
||||
|
||||
it('disables types whose supported signals exclude the current datasource', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
const disabled = Array.from(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).map((el) => el.textContent);
|
||||
|
||||
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
|
||||
expect(disabled).toContain('List');
|
||||
expect(disabled).not.toContain('Time Series');
|
||||
});
|
||||
|
||||
it('does not disable any type when the datasource is unknown', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
expect(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type SectionConfig,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import type { LegendSeries } from '../../hooks/useLegendSeries';
|
||||
import type { TableColumnOption } from '../../hooks/useTableColumns';
|
||||
import { resolveSectionEditor } from '../sectionRegistry';
|
||||
@@ -23,6 +24,9 @@ interface SectionSlotProps {
|
||||
tableColumns: TableColumnOption[];
|
||||
/** Panel's telemetry signal, for editors that fetch field suggestions (List columns). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/** Current panel kind + switch handler, for the visualization section's type switcher. */
|
||||
panelKind: PanelKind;
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +42,8 @@ function SectionSlot({
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
}: SectionSlotProps): JSX.Element | null {
|
||||
// A kind can hide a section based on current spec state (e.g. Histogram legend once
|
||||
// queries are merged) — skip it before resolving the editor.
|
||||
@@ -60,7 +66,12 @@ function SectionSlot({
|
||||
.formatting?.unit;
|
||||
|
||||
return (
|
||||
<SettingsSection title={title} icon={<Icon size={15} />}>
|
||||
<SettingsSection
|
||||
title={title}
|
||||
icon={<Icon size={15} />}
|
||||
// Open Visualization by default so the type switcher is visible.
|
||||
defaultOpen={config.kind === 'visualization'}
|
||||
>
|
||||
<Component
|
||||
value={get(spec)}
|
||||
controls={controls}
|
||||
@@ -69,6 +80,8 @@ function SectionSlot({
|
||||
yAxisUnit={yAxisUnit}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ function renderConfigPane(
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
spec: spec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
legendSeries: [],
|
||||
tableColumns: [],
|
||||
...overrides,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.group {
|
||||
width: min(350px, 100%);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.segment {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Select } from 'antd';
|
||||
|
||||
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
|
||||
@@ -7,7 +8,9 @@ import styles from './ConfigSelect.module.scss';
|
||||
export interface ConfigSelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: SegmentIconName;
|
||||
/** A `SegmentIconName` string (resolved to a glyph), or an arbitrary icon node. */
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ConfigSelectProps {
|
||||
@@ -40,9 +43,14 @@ function ConfigSelect({
|
||||
virtual={false}
|
||||
options={items.map((item) => ({
|
||||
value: item.value,
|
||||
disabled: item.disabled,
|
||||
label: item.icon ? (
|
||||
<span className={styles.item}>
|
||||
<SegmentIcon name={item.icon} />
|
||||
{typeof item.icon === 'string' ? (
|
||||
<SegmentIcon name={item.icon as SegmentIconName} />
|
||||
) : (
|
||||
item.icon
|
||||
)}
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -157,6 +157,10 @@ export interface ErasedSectionDescriptor {
|
||||
// The panel's telemetry signal; read by editors that fetch field-key
|
||||
// suggestions scoped to it (List column picker).
|
||||
signal?: unknown;
|
||||
// Current panel kind + switch handler; read by the visualization section's
|
||||
// type switcher.
|
||||
panelKind?: unknown;
|
||||
onChangePanelKind?: unknown;
|
||||
}>;
|
||||
get: (spec: PanelSpec) => unknown;
|
||||
update: (spec: PanelSpec, value: unknown) => PanelSpec;
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import type { PanelKind } from '../../../../Panels/types/panelKind';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import PanelTypeSwitcher from '../../PanelTypeSwitcher/PanelTypeSwitcher';
|
||||
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
|
||||
|
||||
import styles from './VisualizationSection.module.scss';
|
||||
|
||||
type VisualizationSectionProps = SectionEditorProps<'visualization'> & {
|
||||
/** Current panel kind + switch handler, forwarded by SectionSlot for the type switcher. */
|
||||
panelKind?: PanelKind;
|
||||
onChangePanelKind?: (kind: PanelKind) => void;
|
||||
/** Panel's datasource, forwarded by SectionSlot — scopes the switcher's disabled types. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* Edits the `visualization` slice: the per-panel time preference (all kinds), bar
|
||||
* stacking (`stackedBarChart`, Bar only), and gap filling (`fillSpans`, TimeSeries
|
||||
* only). Each control is gated by its `controls` flag, so a kind only renders — and only
|
||||
* writes — the visualization fields its spec actually supports.
|
||||
* Edits the `visualization` slice: the panel-type switcher (`switchPanelKind`, every
|
||||
* kind), the per-panel time preference, bar stacking (`stackedBarChart`, Bar only), and
|
||||
* gap filling (`fillSpans`, TimeSeries only). Each control is gated by its `controls`
|
||||
* flag, so a kind only renders — and only writes — the fields its spec supports.
|
||||
*/
|
||||
function VisualizationSection({
|
||||
value,
|
||||
controls,
|
||||
onChange,
|
||||
}: SectionEditorProps<'visualization'>): JSX.Element {
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
signal,
|
||||
}: VisualizationSectionProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{controls.switchPanelKind && panelKind && onChangePanelKind && (
|
||||
<PanelTypeSwitcher
|
||||
panelKind={panelKind}
|
||||
signal={signal}
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.timePreference && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel time preference</Typography.Text>
|
||||
|
||||
@@ -4,6 +4,14 @@ import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.s
|
||||
|
||||
import VisualizationSection from '../VisualizationSection';
|
||||
|
||||
// The type switcher resolves each kind's supported signals; stub it so the test
|
||||
// doesn't pull the whole panel registry (renderers, chart libs).
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(() => ({
|
||||
supportedSignals: ['metrics', 'logs', 'traces'],
|
||||
})),
|
||||
}));
|
||||
|
||||
// Open the antd Select by clicking its selector, then pick the option by label.
|
||||
async function pickOption(triggerTestId: string, label: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
@@ -17,7 +25,12 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true, stacking: true, fillSpans: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
stacking: true,
|
||||
fillSpans: true,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -35,7 +48,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -56,7 +72,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -74,7 +93,10 @@ describe('VisualizationSection', () => {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
stackedBarChart: false,
|
||||
}}
|
||||
controls={{ stacking: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
stacking: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -92,7 +114,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={{ fillSpans: false }}
|
||||
controls={{ fillSpans: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
fillSpans: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -101,4 +126,43 @@ describe('VisualizationSection', () => {
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ fillSpans: true });
|
||||
});
|
||||
|
||||
it('renders the type switcher and switches kind when switchPanelKind is set', async () => {
|
||||
const onChangePanelKind = jest.fn();
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ switchPanelKind: true }}
|
||||
onChange={jest.fn()}
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-type-switcher'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await pickOption('panel-editor-v2-type-switcher', 'Table');
|
||||
expect(onChangePanelKind).toHaveBeenCalledWith('signoz/TablePanel');
|
||||
});
|
||||
|
||||
it('hides the type switcher when switchPanelKind is not set', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{
|
||||
switchPanelKind: false,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChangePanelKind={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-type-switcher'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import { defaultColumnsForSignal } from '../ListColumnsEditor/selectFields';
|
||||
import { getSwitchedPluginSpec } from '../getSwitchedPluginSpec';
|
||||
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
}));
|
||||
jest.mock('../ListColumnsEditor/selectFields', () => ({
|
||||
defaultColumnsForSignal: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
const mockDefaultColumnsForSignal =
|
||||
defaultColumnsForSignal as unknown as jest.Mock;
|
||||
|
||||
function specWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind: 'signoz/TablePanel', spec: pluginSpec },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
describe('getSwitchedPluginSpec', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDefaultColumnsForSignal.mockReturnValue([]);
|
||||
});
|
||||
|
||||
it('carries only unit + decimalPrecision when the new kind has a formatting section', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [{ kind: 'formatting', controls: { unit: true, decimals: true } }],
|
||||
});
|
||||
const old = specWith({
|
||||
formatting: { unit: 'ms', decimalPrecision: 2, columnUnits: { A: 'bytes' } },
|
||||
axes: { logScale: true },
|
||||
});
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
old,
|
||||
'signoz/TimeSeriesPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(result.formatting).toStrictEqual({ unit: 'ms', decimalPrecision: 2 });
|
||||
// Type-specific config from the old kind is dropped.
|
||||
expect((result as { axes?: unknown }).axes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not carry formatting when the new kind has no formatting section', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
|
||||
const old = specWith({ formatting: { unit: 'ms' } });
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
old,
|
||||
'signoz/ListPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(result.formatting).toBeUndefined();
|
||||
});
|
||||
|
||||
it('seeds List columns from the signal when switching into a List', () => {
|
||||
const columns = [{ name: 'body' }];
|
||||
mockDefaultColumnsForSignal.mockReturnValue(columns);
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
specWith({}),
|
||||
'signoz/ListPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(mockDefaultColumnsForSignal).toHaveBeenCalledWith(
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
expect(result.selectFields).toBe(columns);
|
||||
});
|
||||
|
||||
it('includes the kind section defaults (e.g. legend position)', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [{ kind: 'legend', controls: { position: true } }],
|
||||
});
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
specWith({}),
|
||||
'signoz/PieChartPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(result.legend?.position).toBe('bottom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { PanelFormattingSlice } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import {
|
||||
buildDefaultPluginSpec,
|
||||
type DefaultPluginSpec,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/buildDefaultPluginSpec';
|
||||
|
||||
import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
|
||||
|
||||
/**
|
||||
* Plugin spec produced on a first-time switch to a new kind. A partial cross-section
|
||||
* of the per-kind spec union; the caller assigns it to `plugin.spec` (typed `unknown`)
|
||||
* at the boundary.
|
||||
*/
|
||||
export interface SwitchedPluginSpec extends DefaultPluginSpec {
|
||||
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the plugin spec for a first-visit switch to `newKind`: the kind's declared
|
||||
* section defaults (so the config pane opens populated, matching new-panel seeding) plus
|
||||
* the only cross-kind config worth keeping — unit + decimal precision. Switching into a
|
||||
* List seeds the current signal's default columns so the columns control isn't empty.
|
||||
*
|
||||
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
|
||||
*/
|
||||
export function getSwitchedPluginSpec(
|
||||
oldSpec: DashboardtypesPanelSpecDTO,
|
||||
newKind: PanelKind,
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): SwitchedPluginSpec {
|
||||
const sections = getPanelDefinition(newKind)?.sections ?? [];
|
||||
const result: SwitchedPluginSpec = buildDefaultPluginSpec(sections);
|
||||
|
||||
if (sections.some((section) => section.kind === 'formatting')) {
|
||||
const oldFormatting = (
|
||||
oldSpec.plugin.spec as {
|
||||
formatting?: PanelFormattingSlice;
|
||||
}
|
||||
).formatting;
|
||||
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
|
||||
...(oldFormatting?.unit !== undefined && { unit: oldFormatting.unit }),
|
||||
...(oldFormatting?.decimalPrecision !== undefined && {
|
||||
decimalPrecision: oldFormatting.decimalPrecision,
|
||||
}),
|
||||
};
|
||||
if (Object.keys(carried).length > 0) {
|
||||
result.formatting = carried;
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.some((section) => section.kind === 'columns')) {
|
||||
const columns = defaultColumnsForSignal(signal);
|
||||
if (columns.length > 0) {
|
||||
result.selectFields = columns;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { handleQueryChange } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
|
||||
import { usePanelTypeSwitch } from '../usePanelTypeSwitch';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('container/NewWidget/utils', () => ({
|
||||
handleQueryChange: jest.fn(),
|
||||
PANEL_TYPE_TO_QUERY_TYPES: {
|
||||
graph: ['builder', 'clickhouse', 'promql'],
|
||||
table: ['builder', 'clickhouse'],
|
||||
list: ['builder'],
|
||||
value: ['builder', 'clickhouse', 'promql'],
|
||||
bar: ['builder', 'clickhouse', 'promql'],
|
||||
pie: ['builder', 'clickhouse'],
|
||||
histogram: ['builder', 'clickhouse', 'promql'],
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
|
||||
toPerses: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../getSwitchedPluginSpec', () => ({
|
||||
getSwitchedPluginSpec: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
|
||||
getBuilderQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
|
||||
const mockToPerses = toPerses as unknown as jest.Mock;
|
||||
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
|
||||
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
|
||||
|
||||
// Opaque sentinels — the leaf utilities are mocked, so only identity matters.
|
||||
const TABLE_PLUGIN_SPEC = { table: true } as unknown;
|
||||
const TABLE_QUERIES = [{ id: 'table-q' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const LIST_PLUGIN_SPEC = { list: true } as unknown;
|
||||
const LIST_QUERIES = [{ id: 'list-q' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const TRANSFORMED = {
|
||||
id: 'transformed',
|
||||
queryType: 'builder',
|
||||
} as unknown as Query;
|
||||
const CONVERTED = [{ id: 'converted' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const SWITCHED_SPEC = { switched: true } as unknown;
|
||||
|
||||
function makeSpec(
|
||||
kind: string,
|
||||
pluginSpec: unknown,
|
||||
queries: NonNullable<DashboardtypesPanelSpecDTO['queries']>,
|
||||
): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind, spec: pluginSpec },
|
||||
queries,
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
const tableSpec = makeSpec(
|
||||
'signoz/TablePanel',
|
||||
TABLE_PLUGIN_SPEC,
|
||||
TABLE_QUERIES,
|
||||
);
|
||||
const listSpec = makeSpec('signoz/ListPanel', LIST_PLUGIN_SPEC, LIST_QUERIES);
|
||||
|
||||
function builderState(currentQuery: Query): {
|
||||
currentQuery: Query;
|
||||
redirectWithQueryBuilderData: jest.Mock;
|
||||
} {
|
||||
return { currentQuery, redirectWithQueryBuilderData: jest.fn() };
|
||||
}
|
||||
|
||||
describe('usePanelTypeSwitch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockHandleQueryChange.mockReturnValue(TRANSFORMED);
|
||||
mockToPerses.mockReturnValue(CONVERTED);
|
||||
mockGetSwitchedPluginSpec.mockReturnValue(SWITCHED_SPEC);
|
||||
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
|
||||
});
|
||||
|
||||
it('does nothing when switching to the current kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
expect(state.redirectWithQueryBuilderData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('on first visit: transforms the query and resets the spec to the new kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
expect(setSpec).toHaveBeenCalledTimes(1);
|
||||
const next = setSpec.mock.calls[0][0] as DashboardtypesPanelSpecDTO;
|
||||
expect(next.plugin.kind).toBe('signoz/ListPanel');
|
||||
expect(next.plugin.spec).toBe(SWITCHED_SPEC);
|
||||
expect(next.queries).toBe(CONVERTED);
|
||||
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(TRANSFORMED);
|
||||
});
|
||||
|
||||
it('coerces the query type when the new kind disallows it (promql → List)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
|
||||
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
// List allows only Query Builder, so the promql query is coerced to 'builder'.
|
||||
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
|
||||
expect((queryArg as Query).queryType).toBe('builder');
|
||||
});
|
||||
|
||||
it('restores the original kind verbatim on switch-back (reversibility)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
|
||||
let state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
|
||||
usePanelTypeSwitch({ ...props, setSpec }),
|
||||
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
|
||||
);
|
||||
|
||||
// Leave Table for List (stashes Table in its pristine state).
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
// Parent re-renders as a List panel; the builder now holds the List query.
|
||||
state = builderState(listQuery);
|
||||
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
|
||||
|
||||
// Switch back to Table → restored from the stash, not re-transformed.
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
const restored = setSpec.mock.calls[
|
||||
setSpec.mock.calls.length - 1
|
||||
][0] as DashboardtypesPanelSpecDTO;
|
||||
expect(restored.plugin.kind).toBe('signoz/TablePanel');
|
||||
expect(restored.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
|
||||
expect(restored.queries).toBe(TABLE_QUERIES);
|
||||
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(tableQuery);
|
||||
// The restore path must not run the query transform again.
|
||||
expect(mockHandleQueryChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelPluginDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
handleQueryChange,
|
||||
PANEL_TYPE_TO_QUERY_TYPES,
|
||||
type PartialPanelTypes,
|
||||
} from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../../Panels/types/panelKind';
|
||||
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
import {
|
||||
getSwitchedPluginSpec,
|
||||
type SwitchedPluginSpec,
|
||||
} from '../getSwitchedPluginSpec';
|
||||
|
||||
/** What a kind looks like when you leave it; restored verbatim if you return. */
|
||||
interface KindState {
|
||||
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
|
||||
queries: DashboardtypesQueryDTO[] | null;
|
||||
builderQuery: Query;
|
||||
}
|
||||
|
||||
interface UsePanelTypeSwitchArgs {
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
panelType: PANEL_TYPES;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
interface UsePanelTypeSwitchApi {
|
||||
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
|
||||
onChangePanelKind: (newKind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
|
||||
* renderer, config sections, query-builder tabs and request type for free; this hook adds
|
||||
* the two things that don't: a per-kind session cache that makes switching reversible
|
||||
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
|
||||
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
|
||||
*/
|
||||
export function usePanelTypeSwitch({
|
||||
spec,
|
||||
panelType,
|
||||
setSpec,
|
||||
}: UsePanelTypeSwitchArgs): UsePanelTypeSwitchApi {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
|
||||
|
||||
// Latest spec/query/type, read inside the stable callback without re-subscribing.
|
||||
const specRef = useRef(spec);
|
||||
specRef.current = spec;
|
||||
const queryRef = useRef(currentQuery);
|
||||
queryRef.current = currentQuery;
|
||||
const panelTypeRef = useRef(panelType);
|
||||
panelTypeRef.current = panelType;
|
||||
|
||||
const onChangePanelKind = useCallback(
|
||||
(newKind: PanelKind): void => {
|
||||
const currentSpec = specRef.current;
|
||||
const oldKind = currentSpec.plugin.kind as PanelKind;
|
||||
if (newKind === oldKind) {
|
||||
return;
|
||||
}
|
||||
const query = queryRef.current;
|
||||
|
||||
cacheRef.current.set(oldKind, {
|
||||
pluginSpec: currentSpec.plugin.spec,
|
||||
queries: currentSpec.queries ?? null,
|
||||
builderQuery: query,
|
||||
});
|
||||
|
||||
const newPanelType = PANEL_KIND_TO_PANEL_TYPE[newKind];
|
||||
|
||||
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
|
||||
// dynamically-chosen kind can't be correlated with its spec statically (as in
|
||||
// `createDefaultPanel`). The surrounding spec stays fully typed.
|
||||
const buildSpec = (
|
||||
pluginSpec: DashboardtypesPanelPluginDTO['spec'] | SwitchedPluginSpec,
|
||||
queries: DashboardtypesQueryDTO[] | null,
|
||||
): DashboardtypesPanelSpecDTO => ({
|
||||
...currentSpec,
|
||||
plugin: {
|
||||
...currentSpec.plugin,
|
||||
kind: newKind,
|
||||
spec: pluginSpec,
|
||||
} as DashboardtypesPanelPluginDTO,
|
||||
queries,
|
||||
});
|
||||
|
||||
// Revisit → restore the stash verbatim (the reversibility path).
|
||||
const cached = cacheRef.current.get(newKind);
|
||||
if (cached) {
|
||||
setSpec(buildSpec(cached.pluginSpec, cached.queries));
|
||||
redirectWithQueryBuilderData(cached.builderQuery);
|
||||
return;
|
||||
}
|
||||
|
||||
// First visit → coerce the query type if the new panel disallows it, then
|
||||
// rebuild the builder query for the new type.
|
||||
const supported = PANEL_TYPE_TO_QUERY_TYPES[newPanelType] ?? [];
|
||||
const queryType = supported.includes(query.queryType)
|
||||
? query.queryType
|
||||
: supported[0];
|
||||
const transformed = handleQueryChange(
|
||||
newPanelType as keyof PartialPanelTypes,
|
||||
{ ...query, queryType },
|
||||
panelTypeRef.current,
|
||||
);
|
||||
const signal = getBuilderQueries(currentSpec.queries || [])[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
|
||||
setSpec(
|
||||
buildSpec(
|
||||
getSwitchedPluginSpec(currentSpec, newKind, signal),
|
||||
toPerses(transformed, newPanelType),
|
||||
),
|
||||
);
|
||||
redirectWithQueryBuilderData(transformed);
|
||||
},
|
||||
[setSpec, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
return { onChangePanelKind };
|
||||
}
|
||||
@@ -29,6 +29,7 @@ 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';
|
||||
@@ -113,6 +114,9 @@ function PanelEditorContainer({
|
||||
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).
|
||||
const isDirty = isNew || isSpecDirty || isQueryDirty;
|
||||
@@ -226,6 +230,7 @@ function PanelEditorContainer({
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { SectionConfig } from '../../types/sections';
|
||||
// Bar stacking lives in `visualization.stackedBarChart`, so it's a `visualization`
|
||||
// control, not `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true, stacking: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true, stacking: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true },
|
||||
},
|
||||
{
|
||||
kind: 'legend',
|
||||
controls: { position: true },
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [];
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'comparison' } },
|
||||
{ kind: 'contextLinks' },
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { SectionConfig } from '../../types/sections';
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
|
||||
// Legend `colors` is omitted: the pie legend is always interactive swatches.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
{ kind: 'contextLinks' },
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { SectionConfig } from '../../types/sections';
|
||||
// single column set). It exposes the per-panel time scope, formatting (decimals +
|
||||
// per-column units), per-column thresholds, and context links.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { decimals: true, columnUnits: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'table' } },
|
||||
{ kind: 'contextLinks' },
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true, fillSpans: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true, fillSpans: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true, colors: true } },
|
||||
|
||||
@@ -89,8 +89,11 @@ export interface SectionControls {
|
||||
spanGaps?: boolean;
|
||||
};
|
||||
buckets: { count?: boolean; width?: boolean; mergeQueries?: boolean };
|
||||
// stacking → stackedBarChart (Bar); fillSpans → fill gaps with 0 (TimeSeries).
|
||||
// switchPanelKind → the visualization-type switcher (every kind, so you can switch
|
||||
// away from any panel); stacking → stackedBarChart (Bar); fillSpans → fill gaps with
|
||||
// 0 (TimeSeries).
|
||||
visualization: {
|
||||
switchPanelKind: boolean;
|
||||
timePreference?: boolean;
|
||||
stacking?: boolean;
|
||||
fillSpans?: boolean;
|
||||
|
||||
@@ -8,7 +8,7 @@ import styles from './PanelTypeSelectionModal.module.scss';
|
||||
interface PanelTypeSelectionModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (pluginKind: PanelKind) => void;
|
||||
onSelect: (panelKind: PanelKind) => void;
|
||||
}
|
||||
|
||||
function PanelTypeSelectionModal({
|
||||
@@ -25,17 +25,17 @@ function PanelTypeSelectionModal({
|
||||
destroyOnClose
|
||||
>
|
||||
<div className={styles.grid}>
|
||||
{PANEL_TYPES.map((type) => (
|
||||
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
|
||||
<Button
|
||||
key={type.pluginKind}
|
||||
key={panelKind}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={styles.typeButton}
|
||||
data-testid={`panel-type-${type.pluginKind}`}
|
||||
onClick={(): void => onSelect(type.pluginKind)}
|
||||
data-testid={`panel-type-${panelKind}`}
|
||||
onClick={(): void => onSelect(panelKind)}
|
||||
>
|
||||
{type.icon}
|
||||
{type.label}
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
BarChart,
|
||||
ChartLine,
|
||||
ChartPie,
|
||||
Hash,
|
||||
List,
|
||||
Table,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import type { PanelType } from './types';
|
||||
|
||||
export const PANEL_TYPES: PanelType[] = [
|
||||
{
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
label: 'Time Series',
|
||||
Icon: ChartLine,
|
||||
},
|
||||
{ panelKind: 'signoz/NumberPanel', label: 'Number', Icon: Hash },
|
||||
{ panelKind: 'signoz/TablePanel', label: 'Table', Icon: Table },
|
||||
{ panelKind: 'signoz/BarChartPanel', label: 'Bar Chart', Icon: BarChart },
|
||||
{ panelKind: 'signoz/PieChartPanel', label: 'Pie Chart', Icon: ChartPie },
|
||||
{ panelKind: 'signoz/HistogramPanel', label: 'Histogram', Icon: BarChart },
|
||||
{ panelKind: 'signoz/ListPanel', label: 'List', Icon: List },
|
||||
];
|
||||
@@ -1,36 +0,0 @@
|
||||
import {
|
||||
BarChart,
|
||||
ChartLine,
|
||||
ChartPie,
|
||||
Hash,
|
||||
List,
|
||||
Table,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import type { PanelType } from './types';
|
||||
|
||||
export const PANEL_TYPES: PanelType[] = [
|
||||
{
|
||||
pluginKind: 'signoz/TimeSeriesPanel',
|
||||
label: 'Time Series',
|
||||
icon: <ChartLine size={16} />,
|
||||
},
|
||||
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
|
||||
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
|
||||
{
|
||||
pluginKind: 'signoz/BarChartPanel',
|
||||
label: 'Bar Chart',
|
||||
icon: <BarChart size={16} />,
|
||||
},
|
||||
{
|
||||
pluginKind: 'signoz/PieChartPanel',
|
||||
label: 'Pie Chart',
|
||||
icon: <ChartPie size={16} />,
|
||||
},
|
||||
{
|
||||
pluginKind: 'signoz/HistogramPanel',
|
||||
label: 'Histogram',
|
||||
icon: <BarChart size={16} />,
|
||||
},
|
||||
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
|
||||
];
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { IconSize } from '@signozhq/icons';
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
|
||||
type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
|
||||
size?: number | IconSize;
|
||||
strokeWidth?: number;
|
||||
};
|
||||
|
||||
export interface PanelType {
|
||||
pluginKind: PanelKind;
|
||||
panelKind: PanelKind;
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
/** Icon component — the consumer renders it and controls size/color/etc. */
|
||||
Icon: ComponentType<IconProps>;
|
||||
}
|
||||
|
||||
@@ -63,9 +63,10 @@ describe('useClonePanel', () => {
|
||||
op: 'add',
|
||||
path: '/spec/layouts/0/spec/items/-',
|
||||
value: {
|
||||
// Same dimensions as the source panel (p1: 8x5).
|
||||
// Same dimensions as the source panel (p1: 8x5). The last row is
|
||||
// full (8 + 4 = 12 cols), so the 8-wide clone wraps to a fresh row
|
||||
// at the section bottom: max(y + height) = 5.
|
||||
x: 0,
|
||||
// Bottom of the section: max(y + height) over existing items = 5.
|
||||
y: 5,
|
||||
width: 8,
|
||||
height: 5,
|
||||
@@ -75,6 +76,27 @@ describe('useClonePanel', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('places the clone beside the last row when it fits', async () => {
|
||||
const oneNarrowItem: DashboardSection[] = [
|
||||
{
|
||||
id: 'section-0',
|
||||
layoutIndex: 0,
|
||||
title: 'Overview',
|
||||
repeatVariable: undefined,
|
||||
items: [{ id: 'p1', x: 0, y: 0, width: 4, height: 5, panel: sourcePanel }],
|
||||
},
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useClonePanel({ sections: oneNarrowItem }),
|
||||
);
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
const ops = mockPatch.mock.calls[0][1];
|
||||
// Room in the last row (4 + 4 = 8 ≤ 12 cols) → sits to the right at y:0.
|
||||
expect(ops[1].value).toMatchObject({ x: 4, y: 0, width: 4, height: 5 });
|
||||
});
|
||||
|
||||
it('deep-copies the spec — the cloned value is not the same object reference', async () => {
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
|
||||
import { addPanelToSectionOps, panelRef } from '../../../patchOps';
|
||||
import {
|
||||
addPanelToSectionOps,
|
||||
findFreeSlot,
|
||||
panelRef,
|
||||
} from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
|
||||
@@ -20,8 +24,9 @@ export interface ClonePanelArgs {
|
||||
|
||||
/**
|
||||
* Duplicates a panel: deep-copies the source spec under a fresh id and drops a
|
||||
* same-size grid item at the bottom of the section, as one atomic patch. Mirrors
|
||||
* V1's clone (verbatim spec copy, no rename).
|
||||
* same-size grid item into the section via `findFreeSlot` (beside the last row
|
||||
* if it fits, else a fresh row), as one atomic patch. Mirrors V1's clone
|
||||
* (verbatim spec copy, no rename).
|
||||
*/
|
||||
export function useClonePanel({
|
||||
sections,
|
||||
@@ -38,10 +43,7 @@ export function useClonePanel({
|
||||
}
|
||||
|
||||
const newPanelId = uuid();
|
||||
const nextY = section.items.reduce(
|
||||
(max, i) => Math.max(max, i.y + i.height),
|
||||
0,
|
||||
);
|
||||
const { x, y } = findFreeSlot(section.items, source.width);
|
||||
|
||||
const clone = patchDashboardV2(
|
||||
{ id: dashboardId },
|
||||
@@ -50,8 +52,8 @@ export function useClonePanel({
|
||||
panel: cloneDeep(source.panel),
|
||||
layoutIndex,
|
||||
item: {
|
||||
x: 0,
|
||||
y: nextY,
|
||||
x,
|
||||
y,
|
||||
width: source.width,
|
||||
height: source.height,
|
||||
content: { $ref: panelRef(newPanelId) },
|
||||
|
||||
@@ -132,13 +132,16 @@ const NEW_PANEL_SIZE = { width: 6, height: 6 };
|
||||
/** Columns in the section grid — mirrors `cols` on SectionGrid's GridLayout. */
|
||||
const GRID_COLS = 12;
|
||||
|
||||
/** Minimal placement fields shared by grid-item DTOs and flattened `GridItem`s. */
|
||||
type PlacedItem = Pick<DashboardGridItemDTO, 'x' | 'y' | 'width' | 'height'>;
|
||||
|
||||
/**
|
||||
* Placement for a new grid item: drop it right of the last row if there's room,
|
||||
* else wrap to a fresh row at the bottom. Only the last row is considered (items
|
||||
* sharing the greatest top-y); gaps in earlier rows are left alone.
|
||||
*/
|
||||
function findFreeSlot(
|
||||
items: DashboardGridItemDTO[],
|
||||
export function findFreeSlot(
|
||||
items: PlacedItem[],
|
||||
width: number,
|
||||
): { x: number; y: number } {
|
||||
const w = Math.min(width, GRID_COLS);
|
||||
|
||||
Reference in New Issue
Block a user