Compare commits

...

8 Commits

Author SHA1 Message Date
Abhi Kumar
61a6fb5d1f feat(dashboard): switch panel type in the editor via the type browser
A revert button returns to the type the panel was opened with.
2026-09-27 17:22:24 +05:30
Abhi Kumar
c4a6ce85fa fix(dashboard): scroll to a placeholder that grows the dashboard
OverlayScrollbars marks its viewport scrollable only after noticing the
overflow, and the grid animates its height, so the reveal either scrolled
html or stopped short.
2026-09-27 17:06:36 +05:30
Abhi Kumar
45a062babc feat(dashboard): preview the new panel where the picker will add it 2026-09-27 17:06:25 +05:30
Abhi Kumar
276056c4ab feat(dashboard): preview a new section on the dashboard while naming it 2026-09-27 16:36:47 +05:30
Abhi Kumar
ffbba01e29 feat(dashboard): pick or create the section from the new-panel drawer footer
The main New Panel button defaults to the dashboard root.
2026-09-27 16:36:47 +05:30
Abhi Kumar
01f7f6869a feat(dashboard): let a new panel's save create its section or the root
Placement travels in the editor URL as a NewPanelTarget, so nothing is
written until the panel is saved.
2026-09-27 16:36:46 +05:30
Abhi Kumar
de3c3c268f feat(dashboard): highlight the picker's target section behind the drawer 2026-09-27 16:36:46 +05:30
Abhi Kumar
9a602d015a feat(dashboard): redesign the new-panel picker as a searchable drawer 2026-09-27 16:36:46 +05:30
59 changed files with 2449 additions and 347 deletions

View File

@@ -25,6 +25,7 @@ interface ConfigPaneProps {
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Switch the panel to another visualization kind. */
onChangePanelKind: (kind: PanelKind) => void;
originalPanelKind?: PanelKind;
/**
* Active query type from the query-builder provider (the selected tab). Drives which
* panel types the visualization switcher disables — read from the provider, not the
@@ -57,6 +58,7 @@ function ConfigPane({
spec,
onChangeSpec,
onChangePanelKind,
originalPanelKind,
queryType,
legendSeries,
tableColumns,
@@ -125,6 +127,7 @@ function ConfigPane({
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
@@ -149,6 +152,7 @@ function ConfigPane({
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}

View File

@@ -1,6 +1,63 @@
@use '../../../../../../styles/scrollbar' as *;
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 5px 5px 5px 12px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
font: inherit;
text-align: left;
cursor: pointer;
&:hover {
border-color: var(--l3-border);
}
}
.triggerIcon {
flex-shrink: 0;
color: var(--l2-foreground);
}
.triggerName {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--l1-foreground);
text-overflow: ellipsis;
white-space: nowrap;
}
.triggerAction {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 3px;
background: var(--l3-background);
color: var(--l2-foreground);
font-size: 12px;
}
.revert {
align-self: flex-start;
}
.drawerBody {
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
@include custom-scrollbar;
}

View File

@@ -1,12 +1,17 @@
import { useCallback, useState } from 'react';
import { ArrowRightLeft, Undo2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeBrowser';
import { getPanelDefinition } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
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). */
@@ -15,32 +20,96 @@ interface PanelTypeSwitcherProps {
queryType: EQueryType;
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
/** Kind the panel was opened with; a revert button appears once it differs. */
originalPanelKind?: PanelKind;
onChange: (kind: PanelKind) => void;
}
/**
* Visualization-type selector (rendered inside the Visualization section). A type is
* disabled when the active query type or signal is incompatible with it — resolved
* through the capabilities guard. The signal is unknown for PromQL/ClickHouse, but
* those query types still disable kinds that only support Query Builder (e.g. List).
* Visualization-type selector (rendered inside the Visualization section): opens the
* panel type browser in a drawer. A type is disabled when the active query type or
* signal is incompatible with it — resolved through the capabilities guard.
*/
function PanelTypeSwitcher({
panelKind,
queryType,
signal,
originalPanelKind,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = usePanelTypeSelectItems({ queryType, signal });
const [isOpen, setIsOpen] = useState(false);
const { displayName, icon: Icon } = getPanelDefinition(panelKind);
const getDisabledReason = useCallback(
(kind: PanelKind): string | undefined =>
getPanelTypeDisabledReason({
kind,
queryType,
signal,
label: getPanelDefinition(kind).displayName,
}),
[queryType, signal],
);
const canRevert = !!originalPanelKind && originalPanelKind !== panelKind;
const revertBlockedReason = canRevert
? getDisabledReason(originalPanelKind)
: undefined;
const handleSelect = (kind: PanelKind): void => {
setIsOpen(false);
if (kind !== panelKind) {
onChange(kind);
}
};
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)}
/>
<button
type="button"
className={styles.trigger}
onClick={(): void => setIsOpen(true)}
data-testid="panel-editor-v2-type-switcher"
>
<Icon size={14} className={styles.triggerIcon} />
<span className={styles.triggerName}>{displayName}</span>
<span className={styles.triggerAction}>
<ArrowRightLeft size={14} />
Change
</span>
</button>
{canRevert && (
<Button
variant="link"
color="primary"
size="sm"
prefix={<Undo2 />}
className={styles.revert}
disabled={!!revertBlockedReason}
title={revertBlockedReason}
onClick={(): void => onChange(originalPanelKind)}
testId="panel-editor-v2-type-revert"
>
Revert to {getPanelDefinition(originalPanelKind).displayName}
</Button>
)}
<DrawerWrapper
open={isOpen}
onOpenChange={setIsOpen}
title="Change panel type"
subTitle="Pick a visualization for this panel."
direction="right"
width="wide"
testId="panel-type-switcher-drawer"
drawerDescriptionProps={{ className: styles.drawerBody }}
>
<PanelTypeBrowser
selectedKind={panelKind}
onSelect={handleSelect}
getDisabledReason={getDisabledReason}
/>
</DrawerWrapper>
</div>
);
}

View File

@@ -1,22 +1,29 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import PanelTypeSwitcher from '../PanelTypeSwitcher';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
const OPTIONS = [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/AreaChartPanel', displayName: 'Area' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
{ kind: 'signoz/TextPanel', displayName: 'Text' },
].map((option) => ({ ...option, icon: (): null => null }));
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
].map((option) => ({ ...option, icon: (): null => null })),
get PANEL_OPTIONS(): unknown {
return OPTIONS;
},
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
@@ -28,14 +35,30 @@ const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
};
function disabledLabels(): (string | null)[] {
return Array.from(
document.querySelectorAll('.ant-select-item-option-disabled'),
).map((el) => el.textContent);
function renderSwitcher(
props: Partial<Parameters<typeof PanelTypeSwitcher>[0]> = {},
): jest.Mock {
const onChange = jest.fn();
render(
<TooltipProvider>
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={onChange}
{...props}
/>
</TooltipProvider>,
);
fireEvent.click(screen.getByTestId('panel-editor-v2-type-switcher'));
return onChange;
}
function openDropdown(): void {
fireEvent.mouseDown(screen.getByRole('combobox'));
function disabledKinds(): (string | undefined)[] {
return Array.from(
document.querySelectorAll('[data-testid^="panel-type-signoz/"]'),
)
.filter((el) => el.getAttribute('aria-disabled') === 'true')
.map((el) => el.getAttribute('data-testid')?.replace('panel-type-', ''));
}
describe('PanelTypeSwitcher', () => {
@@ -44,7 +67,8 @@ describe('PanelTypeSwitcher', () => {
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => ({
mode: 'query',
...OPTIONS.find((option) => option.kind === kind),
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']
@@ -57,83 +81,92 @@ describe('PanelTypeSwitcher', () => {
}));
});
it('fires onChange with the chosen plugin kind', () => {
const onChange = jest.fn();
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={onChange}
/>,
);
it('shows the current type and switches to the chosen one', () => {
const onChange = renderSwitcher();
openDropdown();
fireEvent.click(screen.getByText('List'));
expect(screen.getByTestId('panel-editor-v2-type-switcher')).toHaveTextContent(
'Time SeriesChange',
);
fireEvent.click(screen.getByTestId('panel-type-signoz/ListPanel'));
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
});
it('disables types whose supported signals exclude the current signal', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
signal={TelemetrytypesSignalDTO.metrics}
onChange={jest.fn()}
/>,
);
it('does not fire onChange when the current type is picked again', () => {
const onChange = renderSwitcher();
openDropdown();
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Time Series');
fireEvent.click(screen.getByTestId('panel-type-signoz/TimeSeriesPanel'));
expect(onChange).not.toHaveBeenCalled();
});
it('disables types whose supported signals exclude the current signal', () => {
const onChange = renderSwitcher({ signal: TelemetrytypesSignalDTO.metrics });
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
fireEvent.click(screen.getByTestId('panel-type-signoz/ListPanel'));
expect(onChange).not.toHaveBeenCalled();
});
it('does not disable any type when the signal is unknown (builder, no signal)', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={jest.fn()}
/>,
);
renderSwitcher();
openDropdown();
expect(
document.querySelectorAll('.ant-select-item-option-disabled'),
).toHaveLength(0);
expect(disabledKinds()).toHaveLength(0);
});
it('disables Query-Builder-only kinds under PromQL even without a signal', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.PROM}
onChange={jest.fn()}
/>,
);
renderSwitcher({ queryType: EQueryType.PROM });
openDropdown();
// List/Table/Pie can't be authored in PromQL; Time Series can.
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).toContain('Table');
expect(disabledLabels()).toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
expect(disabledKinds()).toStrictEqual(
expect.arrayContaining([
'signoz/ListPanel',
'signoz/TablePanel',
'signoz/PieChartPanel',
]),
);
expect(disabledKinds()).not.toContain('signoz/TimeSeriesPanel');
expect(disabledKinds()).not.toContain('signoz/TextPanel');
});
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TablePanel"
queryType={EQueryType.CLICKHOUSE}
onChange={jest.fn()}
/>,
);
renderSwitcher({
panelKind: 'signoz/TablePanel',
queryType: EQueryType.CLICKHOUSE,
});
openDropdown();
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Table');
expect(disabledLabels()).not.toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
});
describe('revert', () => {
it('is hidden while the type is the original one', () => {
renderSwitcher({ originalPanelKind: 'signoz/TimeSeriesPanel' });
expect(
screen.queryByTestId('panel-editor-v2-type-revert'),
).not.toBeInTheDocument();
});
it('switches back to the original type', () => {
const onChange = renderSwitcher({
panelKind: 'signoz/TablePanel',
originalPanelKind: 'signoz/TimeSeriesPanel',
});
const revert = screen.getByTestId('panel-editor-v2-type-revert');
expect(revert).toHaveTextContent('Revert to Time Series');
fireEvent.click(revert);
expect(onChange).toHaveBeenCalledWith('signoz/TimeSeriesPanel');
});
it('is disabled when the original type no longer fits the query', () => {
renderSwitcher({
panelKind: 'signoz/TimeSeriesPanel',
originalPanelKind: 'signoz/ListPanel',
queryType: EQueryType.PROM,
});
expect(screen.getByTestId('panel-editor-v2-type-revert')).toBeDisabled();
});
});
});

View File

@@ -18,8 +18,7 @@ interface UsePanelTypeSelectItemsArgs {
/**
* 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.
* the capabilities guard, the same rule the editor's `PanelTypeSwitcher` applies.
*/
export function usePanelTypeSelectItems({
queryType,

View File

@@ -58,6 +58,7 @@ function SectionSlot({
signal,
panelKind,
onChangePanelKind,
originalPanelKind,
queryType,
stepInterval,
metricUnit,
@@ -124,6 +125,7 @@ function SectionSlot({
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}

View File

@@ -16,6 +16,8 @@ export interface SectionEditorContext {
signal?: TelemetrytypesSignalDTO;
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;
/** Kind the panel was opened with, offered as a revert target. */
originalPanelKind?: PanelKind;
yAxisUnit?: string;
queryType?: EQueryType;
stepInterval?: number;

View File

@@ -19,7 +19,11 @@ import styles from './VisualizationSection.module.scss';
type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
Pick<
SectionEditorContext,
'panelKind' | 'onChangePanelKind' | 'signal' | 'queryType'
| 'panelKind'
| 'onChangePanelKind'
| 'originalPanelKind'
| 'signal'
| 'queryType'
>;
/**
@@ -35,6 +39,7 @@ function VisualizationSection({
onChange,
panelKind,
onChangePanelKind,
originalPanelKind,
queryType,
signal,
}: VisualizationSectionProps): JSX.Element {
@@ -47,6 +52,7 @@ function VisualizationSection({
// supplied in practice; default to Query Builder at this boundary.
queryType={queryType ?? EQueryType.QUERY_BUILDER}
signal={signal}
originalPanelKind={originalPanelKind}
onChange={onChangePanelKind}
/>
)}

View File

@@ -11,6 +11,8 @@ import VisualizationSection from '../VisualizationSection';
// the test doesn't pull the whole panel registry (renderers, chart libs).
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(() => ({
displayName: 'Time Series',
icon: (): null => null,
mode: 'query',
supportedSignals: ['metrics', 'logs', 'traces'],
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
@@ -173,7 +175,7 @@ describe('VisualizationSection', () => {
expect(onChange).toHaveBeenCalledWith({ fillSpans: true });
});
it('renders the type switcher and switches kind when switchPanelKind is set', async () => {
it('renders the type switcher and switches kind when switchPanelKind is set', () => {
const onChangePanelKind = jest.fn();
render(
<VisualizationSection
@@ -189,7 +191,8 @@ describe('VisualizationSection', () => {
screen.getByTestId('panel-editor-v2-type-switcher'),
).toBeInTheDocument();
await pickOption('panel-editor-v2-type-switcher', 'Table');
fireEvent.click(screen.getByTestId('panel-editor-v2-type-switcher'));
fireEvent.click(screen.getByTestId('panel-type-signoz/TablePanel'));
expect(onChangePanelKind).toHaveBeenCalledWith('signoz/TablePanel');
});

View File

@@ -39,6 +39,7 @@ import { useTableColumns } from './hooks/useTableColumns';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
import type { NewPanelTarget } from '../patchOps';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
@@ -58,8 +59,7 @@ interface QueryEditorBodyProps {
savedPanel?: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
target?: NewPanelTarget;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
@@ -70,6 +70,7 @@ interface QueryEditorBodyProps {
panelDefinition: RenderableQueryPanelDefinition;
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
onChangePanelKind: (kind: PanelKind) => void;
originalPanelKind?: PanelKind;
}
/**
@@ -84,12 +85,13 @@ function QueryEditorBody({
panel,
savedPanel,
isNew = false,
layoutIndex,
target,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
originalPanelKind,
}: QueryEditorBodyProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
@@ -133,7 +135,7 @@ function QueryEditorBody({
dashboardId,
panelId,
isNew,
layoutIndex,
target,
});
const panelKind = draft.spec.plugin.kind;
@@ -319,6 +321,7 @@ function QueryEditorBody({
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}

View File

@@ -23,6 +23,7 @@ interface StaticEditorBodyProps extends PanelEditorContainerProps {
draftApi: PanelEditorDraftApi;
panelDefinition: RenderableStaticPanelDefinition;
onChangePanelKind: (kind: PanelKind) => void;
originalPanelKind?: PanelKind;
}
/**
@@ -35,12 +36,13 @@ function StaticEditorBody({
dashboardId,
panelId,
isNew = false,
layoutIndex,
target,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
originalPanelKind,
}: StaticEditorBodyProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
@@ -54,7 +56,7 @@ function StaticEditorBody({
dashboardId,
panelId,
isNew,
layoutIndex,
target,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
@@ -122,6 +124,7 @@ function StaticEditorBody({
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={EQueryType.QUERY_BUILDER}
legendSeries={[]}
tableColumns={[]}

View File

@@ -6,14 +6,33 @@ import {
NEW_PANEL_ID,
newPanelSearch,
parseNewPanelKind,
parseNewPanelLayoutIndex,
parseNewPanelTarget,
} from '../newPanelRoute';
describe('newPanelRoute', () => {
it('round-trips kind + layoutIndex through the new-panel search', () => {
const search = newPanelSearch('signoz/ListPanel', 2);
const search = newPanelSearch('signoz/ListPanel', {
type: 'section',
layoutIndex: 2,
});
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe('signoz/ListPanel');
expect(parseNewPanelLayoutIndex(search)).toBe(2);
expect(parseNewPanelTarget(search)).toStrictEqual({
type: 'section',
layoutIndex: 2,
});
});
it.each([
{ type: 'root' as const },
{ type: 'newSection' as const, title: 'Errors & 5xx' },
])('round-trips a $type target', (target) => {
expect(
parseNewPanelTarget(newPanelSearch('signoz/TimeSeriesPanel', target)),
).toStrictEqual(target);
});
it('ignores a blank new section title', () => {
expect(parseNewPanelTarget('?newSection=%20%20')).toBeUndefined();
});
it('omits layoutIndex when not provided', () => {
@@ -21,7 +40,7 @@ describe('newPanelRoute', () => {
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe(
'signoz/TimeSeriesPanel',
);
expect(parseNewPanelLayoutIndex(search)).toBeUndefined();
expect(parseNewPanelTarget(search)).toBeUndefined();
});
it('returns null for an existing panel id (not the new sentinel)', () => {

View File

@@ -14,15 +14,14 @@ import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { transferColumnWidths } from '../../Panels/utils/columnWidthStorage';
import { createPanelOps } from '../../patchOps';
import { createPanelOps, type NewPanelTarget } from '../../patchOps';
interface UsePanelEditorSaveArgs {
dashboardId: string;
panelId: string;
/** Creating a new panel (vs editing an existing one) — adds panel + layout. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
target?: NewPanelTarget;
}
interface UsePanelEditorSaveApi {
@@ -42,7 +41,7 @@ export function usePanelEditorSave({
dashboardId,
panelId,
isNew = false,
layoutIndex,
target,
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
const queryClient = useQueryClient();
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
@@ -60,7 +59,7 @@ export function usePanelEditorSave({
savedPanelId = uuid();
ops = createPanelOps({
layouts: cached?.data.spec.layouts ?? [],
layoutIndex,
target,
panelId: savedPanelId,
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
});
@@ -89,7 +88,7 @@ export function usePanelEditorSave({
});
return savedPanelId;
},
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
[dashboardId, panelId, isNew, target, patchAsync, queryClient],
);
return { save, isSaving: isPatching, error };

View File

@@ -2,6 +2,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { NewPanelTarget } from '../patchOps';
import QueryEditorBody from './QueryEditorBody';
import StaticEditorBody from './StaticEditorBody';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
@@ -18,8 +19,7 @@ export interface PanelEditorContainerProps {
savedPanel?: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
target?: NewPanelTarget;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
@@ -38,6 +38,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
const panelKind = draftApi.draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
const originalPanelKind = (savedPanel ?? panel).spec.plugin.kind;
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
@@ -52,6 +53,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
/>
);
}
@@ -62,6 +64,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
/>
);
}

View File

@@ -4,26 +4,33 @@ import type { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import type { NewPanelTarget } from '../patchOps';
import { PANELS } from '../Panels/registry';
import {
PANEL_TYPE_TO_PANEL_KIND,
type PanelKind,
} from '../Panels/types/panelKind';
// New (unsaved) panels use a fixed id segment, carrying kind + target section in the
// query (`/panel/new?panelKind=…&layoutIndex=…`); the real id is generated on save.
// New (unsaved) panels use a fixed id segment, carrying kind + target in the query
// (`/panel/new?panelKind=…&layoutIndex=<index|root>` or `&newSection=<title>`).
export const NEW_PANEL_ID = 'new';
const PANEL_KIND_PARAM = 'panelKind';
const LAYOUT_INDEX_PARAM = 'layoutIndex';
const NEW_SECTION_PARAM = 'newSection';
const ROOT_LAYOUT = 'root';
/** Query string (incl. leading `?`) for the new-panel editor route. */
export function newPanelSearch(
panelKind: PanelKind,
layoutIndex?: number,
target?: NewPanelTarget,
): string {
const params = new URLSearchParams({ [PANEL_KIND_PARAM]: panelKind });
if (layoutIndex !== undefined) {
params.set(LAYOUT_INDEX_PARAM, String(layoutIndex));
if (target?.type === 'section') {
params.set(LAYOUT_INDEX_PARAM, String(target.layoutIndex));
} else if (target?.type === 'root') {
params.set(LAYOUT_INDEX_PARAM, ROOT_LAYOUT);
} else if (target?.type === 'newSection') {
params.set(NEW_SECTION_PARAM, target.title);
}
return `?${params.toString()}`;
}
@@ -75,12 +82,21 @@ export function buildExportPanelLink({
}=${encodeURIComponent(encodeURIComponent(JSON.stringify(query)))}`;
}
/** Target section index for a new panel, or undefined when unset/invalid. */
export function parseNewPanelLayoutIndex(search: string): number | undefined {
const raw = new URLSearchParams(search).get(LAYOUT_INDEX_PARAM);
export function parseNewPanelTarget(
search: string,
): NewPanelTarget | undefined {
const params = new URLSearchParams(search);
const title = params.get(NEW_SECTION_PARAM)?.trim();
if (title) {
return { type: 'newSection', title };
}
const raw = params.get(LAYOUT_INDEX_PARAM);
if (raw === ROOT_LAYOUT) {
return { type: 'root' };
}
if (raw === null || raw === '') {
return undefined;
}
const n = Number(raw);
return Number.isNaN(n) ? undefined : n;
return Number.isNaN(n) ? undefined : { type: 'section', layoutIndex: n };
}

View File

@@ -0,0 +1,184 @@
.browser {
display: flex;
flex-direction: column;
}
.controls {
position: sticky;
// Cover the drawer body's top padding so tiles don't peek above when stuck.
top: -16px;
z-index: 1;
display: flex;
flex-direction: column;
gap: 14px;
margin-top: -16px;
padding-block: 16px;
border-bottom: 1px dashed var(--l1-border);
// Matches the drawer surface, which the app pins to l1 for every drawer.
background: var(--l1-background);
}
.search {
box-sizing: border-box;
--input-wrapper-background: var(--l2-background);
--input-wrapper-border-color: var(--l2-border);
--input-hover-border-color: var(--l3-border);
--input-box-shadow: none;
}
.categories {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.category {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
border: 1px solid var(--l1-border);
border-radius: 999px;
background: transparent;
color: var(--l2-foreground);
font: inherit;
font-size: 12px;
white-space: nowrap;
cursor: pointer;
&:hover {
color: var(--l1-foreground);
}
}
.categoryActive {
border-color: var(--l3-border);
background: var(--l3-background);
color: var(--l1-foreground);
}
.categoryCount {
color: var(--l3-foreground);
font-size: 10.5px;
}
.groups {
display: flex;
flex-direction: column;
gap: 24px;
padding-top: 20px;
}
.group {
display: flex;
flex-direction: column;
gap: 12px;
}
.groupLabel {
color: var(--bg-sienna-400);
font-size: 11px;
letter-spacing: 0.88px;
text-transform: uppercase;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.tile {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border: 1px solid var(--l1-border);
border-radius: 6px;
background: var(--l2-background);
font: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1),
border-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1);
&:hover {
background: var(--l3-background);
}
}
.tileSelected {
border-color: var(--bg-robin-500);
box-shadow: inset 0 0 0 1px var(--bg-robin-500);
background: var(--l3-background);
}
.tileDisabled {
opacity: 0.45;
cursor: not-allowed;
&:hover {
background: var(--l2-background);
}
}
.tileText {
display: flex;
flex-direction: column;
gap: 3px;
}
.tileTitle {
display: flex;
align-items: center;
gap: 7px;
}
.tileName {
color: var(--l1-foreground);
font-size: 13.5px;
font-weight: 500;
}
.newBadge {
padding: 2px 6px;
border: 1px solid var(--bg-sienna-400);
border-radius: 3px;
color: var(--bg-sienna-400);
font-size: 9.5px;
font-weight: 500;
letter-spacing: 0.6px;
text-transform: uppercase;
}
.tileDescription {
color: var(--l2-foreground);
font-size: 12px;
line-height: 1.45;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 48px 0;
color: var(--l2-foreground);
font-size: 13px;
text-align: center;
}
.clearSearch {
padding: 0;
border: none;
background: none;
color: var(--bg-robin-500);
font: inherit;
font-size: 12.5px;
cursor: pointer;
&:hover {
color: var(--bg-robin-400);
}
}

View File

@@ -0,0 +1,118 @@
import { useMemo, useState } from 'react';
import { Search } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import cx from 'classnames';
import type { PanelKind } from '../../../Panels/types/panelKind';
import {
filterPanelTypeGroups,
type PanelTypeGroupId,
} from './panelTypeCatalog';
import PanelTypeTile from './PanelTypeTile';
import styles from './PanelTypeBrowser.module.scss';
type CategoryId = PanelTypeGroupId | 'all';
interface PanelTypeBrowserProps {
selectedKind: PanelKind;
onSelect: (kind: PanelKind) => void;
getDisabledReason?: (kind: PanelKind) => string | undefined;
}
/** Searchable, category-filtered grid of panel types, grouped by purpose. */
function PanelTypeBrowser({
selectedKind,
onSelect,
getDisabledReason,
}: PanelTypeBrowserProps): JSX.Element {
const [query, setQuery] = useState('');
const [category, setCategory] = useState<CategoryId>('all');
const matched = useMemo(() => filterPanelTypeGroups(query), [query]);
const categories = useMemo(
() => [
{
id: 'all' as const,
label: 'All',
count: matched.reduce((sum, group) => sum + group.items.length, 0),
},
...matched.map(({ id, label, items }) => ({
id,
label,
count: items.length,
})),
],
[matched],
);
const visibleGroups = matched.filter(
(group) =>
group.items.length > 0 && (category === 'all' || group.id === category),
);
return (
<div className={styles.browser}>
<div className={styles.controls}>
<Input
value={query}
onChange={(e): void => setQuery(e.target.value)}
placeholder="Search panel types"
prefix={<Search size={14} />}
testId="panel-type-search"
containerClassName={styles.search}
/>
<div className={styles.categories}>
{categories.map(({ id, label, count }) => (
<button
key={id}
type="button"
className={cx(styles.category, {
[styles.categoryActive]: category === id,
})}
aria-pressed={category === id}
onClick={(): void => setCategory(id)}
>
{label}
<span className={styles.categoryCount}>{count}</span>
</button>
))}
</div>
</div>
<div className={styles.groups}>
{visibleGroups.map((group) => (
<section key={group.id} className={styles.group}>
<span className={styles.groupLabel}>{group.label}</span>
<div className={styles.grid}>
{group.items.map((item) => (
<PanelTypeTile
key={item.kind}
item={item}
isSelected={item.kind === selectedKind}
disabledReason={getDisabledReason?.(item.kind)}
onSelect={(): void => onSelect(item.kind)}
/>
))}
</div>
</section>
))}
{visibleGroups.length === 0 && (
<div className={styles.empty}>
<span>No panel types match “{query}”</span>
<button
type="button"
className={styles.clearSearch}
onClick={(): void => setQuery('')}
>
Clear search
</button>
</div>
)}
</div>
</div>
);
}
export default PanelTypeBrowser;

View File

@@ -0,0 +1,124 @@
.preview {
height: 66px;
padding: 10px;
box-sizing: border-box;
overflow: hidden;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l1-background);
}
.svg {
display: block;
width: 100%;
height: 100%;
}
.bars,
.histogram {
display: flex;
align-items: flex-end;
height: 100%;
}
.bars {
gap: 7px;
}
.histogram {
gap: 2px;
}
.bar {
flex: 1;
border-radius: 1px;
background: var(--bg-robin-500);
}
.number {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
height: 100%;
}
.numberValue {
color: var(--l1-foreground);
font-size: 21px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.5px;
}
.numberUnit {
color: var(--l3-foreground);
font-size: 12px;
}
.numberLabel {
color: var(--l3-foreground);
font-size: 8px;
letter-spacing: 0.6px;
text-transform: uppercase;
}
.rows {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
}
.tableRow {
display: grid;
grid-template-columns: 1.4fr 1fr 0.7fr;
gap: 8px;
}
.listRow {
display: flex;
align-items: center;
gap: 6px;
.cell {
flex: 1;
}
}
.cell,
.headCell,
.accentCell {
display: block;
height: 4px;
border-radius: 2px;
}
.cell {
background: var(--l3-background);
}
.headCell {
background: var(--l3-border);
}
.accentCell {
background: var(--bg-robin-500);
opacity: 0.6;
}
.dot,
.accentDot {
width: 4px;
height: 4px;
border-radius: 50%;
}
.dot {
background: var(--l3-border);
}
.accentDot {
background: var(--bg-robin-500);
}

View File

@@ -0,0 +1,14 @@
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPE_PREVIEWS } from './panelTypePreviews';
import styles from './PanelTypePreview.module.scss';
function PanelTypePreview({ kind }: { kind: PanelKind }): JSX.Element {
return (
<div className={styles.preview} aria-hidden>
{PANEL_TYPE_PREVIEWS[kind]}
</div>
);
}
export default PanelTypePreview;

View File

@@ -1,69 +1,33 @@
.panelTypeSection {
@use '../../../../../../styles/scrollbar' as *;
.body {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
gap: 16px;
min-height: 0;
overflow-y: auto;
@include custom-scrollbar;
}
.grid {
align-self: stretch;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.panelTypeCard {
.footer {
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid var(--l2-border);
background: var(--l2-background);
padding: 12px;
gap: 12px;
cursor: pointer;
font: inherit;
border-radius: 4px;
color: var(--l1-foreground);
transition:
transform 180ms ease,
border-color 180ms ease;
&:hover {
background-color: var(--l2-background-hover);
border-color: var(--bg-robin-400);
}
&:active {
transform: translateY(2px);
}
}
.panelTypeCardSelected {
border-color: var(--bg-robin-400);
background-color: var(--l2-background-hover);
box-shadow: inset 0 0 0 1px var(--bg-robin-400);
}
.footerActions {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 8px;
gap: 10px;
width: 100%;
}
.footerPicker {
// Take all the width left over by the (natural-width) confirm button.
.summary {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
align-items: center;
gap: 8px;
min-width: 0;
color: var(--l3-foreground);
font-size: 12.5px;
white-space: nowrap;
}
.pickerLabel {
color: var(--l3-foreground);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
.summaryKind {
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -1,19 +1,27 @@
import { useEffect, useMemo, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { DialogWrapper } from '@signozhq/ui/dialog';
import cx from 'classnames';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { useDashboardSections } from '../../../hooks/useDashboardSections';
import { PANEL_OPTIONS } from '../../../Panels/registry';
import { releasePanelPickerTarget } from '../../../store/usePanelPickerTargetStore';
import { getPanelDefinition } from '../../../Panels/registry';
import type { NewPanelTarget } from '../../../patchOps';
import type { PanelKind } from '../../../Panels/types/panelKind';
import PanelTypeSelectionModalFooter from './PanelTypeSelectionModalFooter';
import PanelTypeBrowser from './PanelTypeBrowser';
import SectionTarget from './SectionTarget';
import { usePanelPickerDraftSection } from './usePanelPickerDraftSection';
import { usePanelPickerTarget } from './usePanelPickerTarget';
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
import styles from './PanelTypeSelectionModal.module.scss';
const DEFAULT_PANEL_KIND: PanelKind = 'signoz/TimeSeriesPanel';
interface PanelTypeSelectionModalProps {
open: boolean;
onClose: () => void;
onSelect: (panelKind: PanelKind, layoutIndex?: number) => void;
onSelect: (panelKind: PanelKind, target?: NewPanelTarget) => void;
/** Section the picker opens on; omit → the first section. */
defaultLayoutIndex?: number;
}
@@ -26,89 +34,105 @@ function PanelTypeSelectionModal({
}: PanelTypeSelectionModalProps): JSX.Element {
const sections = useDashboardSections();
const options = useMemo(() => buildSectionOptions(sections), [sections]);
// With more than one section the user must pick a target section, so we keep
// the select-then-confirm flow. Otherwise there's nothing to choose: hide the
// footer and let a tile click create the panel outright.
const hasSectionPicker = options.length > 1;
const [selectedValue, setSelectedValue] = useState('');
const [selectedPanelKind, setSelectedPanelKind] = useState<PanelKind | null>(
null,
);
const [selectedKind, setSelectedKind] =
useState<PanelKind>(DEFAULT_PANEL_KIND);
const [newSectionTitle, setNewSectionTitle] = useState<string | null>(null);
const isCreatingSection = newSectionTitle !== null;
// Seed the target section on open.
useEffect(() => {
if (open) {
setSelectedValue(resolveDefaultSectionValue(options, defaultLayoutIndex));
setSelectedPanelKind(null);
setSelectedKind(DEFAULT_PANEL_KIND);
setNewSectionTitle(null);
}
}, [open, options, defaultLayoutIndex]);
const createPanel = (panelKind: PanelKind): void => {
const layoutIndex = selectedValue === '' ? undefined : Number(selectedValue);
onSelect(panelKind, layoutIndex);
const selectedTarget = options.find((o) => o.value === selectedValue)?.target;
const selectedLayoutIndex =
selectedTarget?.type === 'section' ? selectedTarget.layoutIndex : undefined;
usePanelPickerTarget({
open: open && !isCreatingSection,
layoutIndex: selectedLayoutIndex,
panelKind: selectedKind,
outline: hasSectionPicker,
});
usePanelPickerDraftSection(newSectionTitle, selectedKind, open);
const handleClose = (): void => {
releasePanelPickerTarget(true);
onClose();
};
const handleTileClick = (panelKind: PanelKind): void => {
if (hasSectionPicker) {
setSelectedPanelKind(panelKind);
return;
}
createPanel(panelKind);
};
const sectionTitle = newSectionTitle?.trim() ?? '';
const handleConfirm = (): void => {
if (selectedPanelKind === null) {
if (isCreatingSection && !sectionTitle) {
return;
}
createPanel(selectedPanelKind);
releasePanelPickerTarget(false);
onSelect(
selectedKind,
isCreatingSection
? { type: 'newSection', title: sectionTitle }
: selectedTarget,
);
};
const selectedName = getPanelDefinition(selectedKind).displayName;
return (
<DialogWrapper
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
handleClose();
}
}}
title="New Panel"
title="New panel"
subTitle="Pick a visualization. You can change it later."
direction="right"
width="wide"
testId="panel-type-drawer"
drawerDescriptionProps={{ className: styles.body }}
footer={
hasSectionPicker ? (
<PanelTypeSelectionModalFooter
options={options}
selectedValue={selectedValue}
onSectionChange={setSelectedValue}
isConfirmDisabled={selectedPanelKind === null}
onConfirm={handleConfirm}
/>
) : undefined
<div className={styles.footer}>
<span className={styles.summary}>
<span className={styles.summaryKind}>{selectedName}</span>
<SectionTarget
options={options}
value={selectedValue}
onChange={setSelectedValue}
newSectionTitle={newSectionTitle}
onNewSectionTitleChange={setNewSectionTitle}
onSubmit={handleConfirm}
/>
</span>
<Button
variant="outlined"
color="secondary"
size="md"
onClick={handleClose}
>
Cancel
</Button>
<Button
color="primary"
size="md"
prefix={<Plus size={16} />}
onClick={handleConfirm}
disabled={isCreatingSection && !sectionTitle}
testId="panel-type-confirm"
>
Add panel
</Button>
</div>
}
>
<div className={styles.panelTypeSection}>
{hasSectionPicker && (
<span className={styles.pickerLabel}>Select panel type</span>
)}
<div className={styles.grid}>
{PANEL_OPTIONS.map(({ kind, displayName, icon: Icon }) => (
<button
key={kind}
type="button"
className={cx(styles.panelTypeCard, {
[styles.panelTypeCardSelected]: kind === selectedPanelKind,
})}
data-testid={`panel-type-${kind}`}
aria-pressed={kind === selectedPanelKind}
onClick={(): void => handleTileClick(kind)}
>
<Icon size={24} color={Color.BG_ROBIN_400} />
{displayName}
</button>
))}
</div>
</div>
</DialogWrapper>
<PanelTypeBrowser selectedKind={selectedKind} onSelect={setSelectedKind} />
</DrawerWrapper>
);
}

View File

@@ -1,53 +0,0 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import SectionPicker from './SectionPicker';
import type { SectionOption } from './types';
import styles from './PanelTypeSelectionModal.module.scss';
interface PanelTypeSelectionModalFooterProps {
options: SectionOption[];
selectedValue: string;
onSectionChange: (value: string) => void;
/** Disabled until a panel type is picked. */
isConfirmDisabled: boolean;
onConfirm: () => void;
}
/**
* Footer for the New Panel modal: an "Add panel to" section picker and the
* confirm button. Only rendered when the dashboard has more than one section —
* otherwise there's nothing to pick and a tile click creates the panel directly.
*/
function PanelTypeSelectionModalFooter({
options,
selectedValue,
onSectionChange,
isConfirmDisabled,
onConfirm,
}: PanelTypeSelectionModalFooterProps): JSX.Element {
return (
<div className={styles.footerActions}>
<div className={styles.footerPicker}>
<span className={styles.pickerLabel}>Add panel to</span>
<SectionPicker
options={options}
value={selectedValue}
onChange={onSectionChange}
/>
</div>
<Button
color="primary"
size="md"
disabled={isConfirmDisabled}
prefix={<Plus size={16} />}
onClick={onConfirm}
testId="panel-type-confirm"
>
Add Panel
</Button>
</div>
);
}
export default PanelTypeSelectionModalFooter;

View File

@@ -0,0 +1,54 @@
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import type { PanelTypeItem } from './panelTypeCatalog';
import PanelTypePreview from './PanelTypePreview';
import styles from './PanelTypeBrowser.module.scss';
interface PanelTypeTileProps {
item: PanelTypeItem;
isSelected: boolean;
/** Why the kind can't be picked; the tile is disabled when set. */
disabledReason?: string;
onSelect: () => void;
}
function PanelTypeTile({
item,
isSelected,
disabledReason,
onSelect,
}: PanelTypeTileProps): JSX.Element {
const tile = (
<button
type="button"
className={cx(styles.tile, {
[styles.tileSelected]: isSelected,
[styles.tileDisabled]: !!disabledReason,
})}
data-testid={`panel-type-${item.kind}`}
aria-pressed={isSelected}
// aria-disabled, not disabled, so the reason tooltip still gets hover events.
aria-disabled={!!disabledReason}
onClick={disabledReason ? undefined : onSelect}
>
<PanelTypePreview kind={item.kind} />
<span className={styles.tileText}>
<span className={styles.tileTitle}>
<span className={styles.tileName}>{item.displayName}</span>
{item.isNew && <span className={styles.newBadge}>New</span>}
</span>
<span className={styles.tileDescription}>{item.description}</span>
</span>
</button>
);
return disabledReason ? (
<TooltipSimple title={disabledReason}>{tile}</TooltipSimple>
) : (
tile
);
}
export default PanelTypeTile;

View File

@@ -0,0 +1,29 @@
import styles from './PanelTypePreview.module.scss';
interface PreviewBarsProps {
heights: number[];
/** Fade each successive bar (ranked bars) instead of a uniform opacity. */
fade: boolean;
className: string;
}
function PreviewBars({
heights,
fade,
className,
}: PreviewBarsProps): JSX.Element {
return (
<div className={className}>
{heights.map((height, i) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={i}
className={styles.bar}
style={{ height: `${height}%`, opacity: fade ? 1 - i * 0.15 : 0.75 }}
/>
))}
</div>
);
}
export default PreviewBars;

View File

@@ -0,0 +1,34 @@
import styles from './PanelTypePreview.module.scss';
export interface PreviewCell {
className: string;
/** Percent of the row width. */
width?: number;
}
interface PreviewRowsProps {
rows: PreviewCell[][];
rowClassName?: string;
}
function PreviewRows({ rows, rowClassName }: PreviewRowsProps): JSX.Element {
return (
<div className={styles.rows}>
{rows.map((cells, row) => (
// eslint-disable-next-line react/no-array-index-key
<div key={row} className={rowClassName}>
{cells.map(({ className, width }, col) => (
<span
// eslint-disable-next-line react/no-array-index-key
key={col}
className={className}
style={width === undefined ? undefined : { width: `${width}%` }}
/>
))}
</div>
))}
</div>
);
}
export default PreviewRows;

View File

@@ -1,5 +1,16 @@
.select {
width: 100%;
flex: 0 1 220px;
min-width: 120px;
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
background: var(--l2-background) !important;
box-shadow: none !important;
}
&:hover :global(.ant-select-selector) {
border-color: var(--l3-border) !important;
}
}
.dropdown {
@@ -53,3 +64,22 @@
font-size: 12px;
line-height: 1.2;
}
.createOption {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
margin-top: 4px;
padding: 8px 12px;
border: none;
border-top: 1px solid var(--l2-border);
background: transparent;
color: var(--l2-foreground);
cursor: pointer;
&:hover {
color: var(--l1-foreground);
background: var(--l2-background-hover);
}
}

View File

@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { Plus } from '@signozhq/icons';
// eslint-disable-next-line signoz/no-antd-components
import { Select } from 'antd';
@@ -9,12 +10,14 @@ interface SectionPickerProps {
options: SectionOption[];
value: string;
onChange: (value: string) => void;
onCreate: () => void;
}
function SectionPicker({
options,
value,
onChange,
onCreate,
}: SectionPickerProps): JSX.Element {
// `selectedLabel` (one line) shows in the trigger; `label` (two lines) in the list.
const selectOptions = useMemo(
@@ -32,7 +35,7 @@ function SectionPicker({
label: (
<span
className={styles.optionRow}
data-testid={`panel-section-option-${option.layoutIndex}`}
data-testid={`panel-section-option-${option.value}`}
>
<option.Icon size={16} className={iconClass} />
<span className={styles.optionText}>
@@ -50,6 +53,8 @@ function SectionPicker({
<Select<string>
className={styles.select}
popupClassName={styles.dropdown}
placement="topLeft"
popupMatchSelectWidth={false}
value={value}
onChange={onChange}
data-testid="panel-section-select"
@@ -58,6 +63,22 @@ function SectionPicker({
trigger.parentElement ?? document.body
}
options={selectOptions}
dropdownRender={(menu): JSX.Element => (
<>
{menu}
<button
type="button"
className={styles.createOption}
// Keeps focus on the select so the click isn't lost to its blur.
onMouseDown={(e): void => e.preventDefault()}
onClick={onCreate}
data-testid="panel-section-create"
>
<Plus size={14} />
New section
</button>
</>
)}
/>
);
}

View File

@@ -0,0 +1,4 @@
.nameInput {
flex: 0 1 220px;
min-width: 120px;
}

View File

@@ -0,0 +1,96 @@
import type { KeyboardEvent } from 'react';
import { Plus, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import SectionPicker from './SectionPicker';
import type { SectionOption } from './types';
import styles from './SectionTarget.module.scss';
interface SectionTargetProps {
options: SectionOption[];
value: string;
onChange: (value: string) => void;
/** Name of the section to create; null when targeting an existing one. */
newSectionTitle: string | null;
onNewSectionTitleChange: (title: string | null) => void;
onSubmit: () => void;
}
function SectionTarget({
options,
value,
onChange,
newSectionTitle,
onNewSectionTitleChange,
onSubmit,
}: SectionTargetProps): JSX.Element {
const startCreating = (): void => onNewSectionTitleChange('');
if (newSectionTitle !== null) {
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
onSubmit();
} else if (e.key === 'Escape') {
// Drop the draft only, not the drawer.
e.stopPropagation();
onNewSectionTitleChange(null);
}
};
return (
<>
in
<Input
autoFocus
value={newSectionTitle}
placeholder="New section name"
className={styles.nameInput}
onChange={(e): void => onNewSectionTitleChange(e.target.value)}
onKeyDown={handleKeyDown}
testId="panel-section-name"
/>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Cancel new section"
onClick={(): void => onNewSectionTitleChange(null)}
testId="panel-section-name-cancel"
>
<X size={14} />
</Button>
</>
);
}
if (options.length > 1) {
return (
<>
in
<SectionPicker
options={options}
value={value}
onChange={onChange}
onCreate={startCreating}
/>
</>
);
}
return (
<Button
variant="dashed"
color="secondary"
size="md"
prefix={<Plus />}
onClick={startCreating}
testId="panel-section-create"
>
Add to new section
</Button>
);
}
export default SectionTarget;

View File

@@ -0,0 +1,286 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { useDashboardSections } from '../../../../hooks/useDashboardSections';
import { usePanelPickerTargetStore } from '../../../../store/usePanelPickerTargetStore';
import PanelTypeSelectionModal from '../PanelTypeSelectionModal';
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
jest.mock('../../../../Panels/registry', () => {
const options = [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/AreaChartPanel', displayName: 'Area' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
{ kind: 'signoz/TextPanel', displayName: 'Text' },
].map((option) => ({ ...option, icon: (): null => null }));
return {
PANEL_OPTIONS: options,
getPanelDefinition: (kind: string): unknown =>
options.find((option) => option.kind === kind),
};
});
jest.mock('../../../../hooks/useDashboardSections', () => ({
useDashboardSections: jest.fn(),
}));
const mockUseDashboardSections = useDashboardSections as jest.Mock;
const ROOT_ONLY = [{ layoutIndex: 0, title: undefined, panelIds: [] }];
const WITH_SECTIONS = [
{ layoutIndex: 0, title: 'Overview', panelIds: [] },
{ layoutIndex: 1, title: 'Latency', panelIds: [] },
];
function renderDrawer(
props: Partial<Parameters<typeof PanelTypeSelectionModal>[0]> = {},
): { onSelect: jest.Mock; onClose: jest.Mock } {
const onSelect = jest.fn();
const onClose = jest.fn();
render(
<PanelTypeSelectionModal
open
onClose={onClose}
onSelect={onSelect}
{...props}
/>,
);
return { onSelect, onClose };
}
describe('PanelTypeSelectionModal', () => {
beforeEach(() => {
mockUseDashboardSections.mockReturnValue(ROOT_ONLY);
usePanelPickerTargetStore.getState().reset();
});
it('adds the default Time Series panel when confirmed untouched', () => {
const { onSelect } = renderDrawer();
fireEvent.click(screen.getByTestId('panel-type-confirm'));
expect(onSelect).toHaveBeenCalledWith('signoz/TimeSeriesPanel', {
type: 'section',
layoutIndex: 0,
});
});
it('selects a tile, then adds it on confirm', () => {
const { onSelect } = renderDrawer();
fireEvent.click(screen.getByTestId('panel-type-signoz/TablePanel'));
expect(onSelect).not.toHaveBeenCalled();
expect(screen.getByTestId('panel-type-signoz/TablePanel')).toHaveAttribute(
'aria-pressed',
'true',
);
fireEvent.click(screen.getByTestId('panel-type-confirm'));
expect(onSelect).toHaveBeenCalledWith('signoz/TablePanel', {
type: 'section',
layoutIndex: 0,
});
});
it('hides the section picker when the dashboard has a single layout', () => {
renderDrawer();
expect(screen.queryByTestId('panel-section-select')).not.toBeInTheDocument();
});
it('targets the section it was opened against', () => {
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
const { onSelect } = renderDrawer({ defaultLayoutIndex: 1 });
expect(screen.getByTestId('panel-section-select')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('panel-type-confirm'));
expect(onSelect).toHaveBeenCalledWith('signoz/TimeSeriesPanel', {
type: 'section',
layoutIndex: 1,
});
});
it('defaults to the root on a sectioned dashboard, even one without a root', () => {
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
const { onSelect } = renderDrawer();
fireEvent.click(screen.getByTestId('panel-type-confirm'));
expect(onSelect).toHaveBeenCalledWith('signoz/TimeSeriesPanel', {
type: 'root',
});
});
it('filters tiles by search and offers to clear an empty result', () => {
renderDrawer();
const search = screen.getByTestId('panel-type-search');
fireEvent.change(search, { target: { value: 'markdown' } });
expect(screen.getByTestId('panel-type-signoz/TextPanel')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-type-signoz/TablePanel'),
).not.toBeInTheDocument();
fireEvent.change(search, { target: { value: 'zzz' } });
expect(screen.getByText('No panel types match “zzz”')).toBeInTheDocument();
fireEvent.click(screen.getByText('Clear search'));
expect(
screen.getByTestId('panel-type-signoz/TablePanel'),
).toBeInTheDocument();
});
it('narrows tiles to the chosen category', () => {
renderDrawer();
fireEvent.click(screen.getByRole('button', { name: /Raw records/ }));
expect(screen.getByTestId('panel-type-signoz/ListPanel')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-type-signoz/TimeSeriesPanel'),
).not.toBeInTheDocument();
});
it('closes on Cancel', () => {
const { onClose, onSelect } = renderDrawer();
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
});
describe('new section', () => {
it('asks for the named section, created when the panel is saved', () => {
const { onSelect } = renderDrawer();
fireEvent.click(screen.getByTestId('panel-section-create'));
const confirm = screen.getByTestId('panel-type-confirm');
expect(confirm).toBeDisabled();
fireEvent.change(screen.getByTestId('panel-section-name'), {
target: { value: ' Errors ' },
});
fireEvent.click(confirm);
expect(onSelect).toHaveBeenCalledWith('signoz/TimeSeriesPanel', {
type: 'newSection',
title: 'Errors',
});
});
it('publishes the draft for the dashboard preview', () => {
const draft = (): unknown =>
usePanelPickerTargetStore.getState().draftSection;
renderDrawer();
fireEvent.click(screen.getByTestId('panel-section-create'));
expect(draft()).toStrictEqual({
title: '',
panelKind: 'signoz/TimeSeriesPanel',
});
fireEvent.change(screen.getByTestId('panel-section-name'), {
target: { value: 'Errors' },
});
fireEvent.click(screen.getByTestId('panel-type-signoz/TablePanel'));
expect(draft()).toStrictEqual({
title: 'Errors',
panelKind: 'signoz/TablePanel',
});
fireEvent.click(screen.getByTestId('panel-section-name-cancel'));
expect(draft()).toBeNull();
});
it('returns to the section picker when the draft is cancelled', () => {
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
renderDrawer();
fireEvent.mouseDown(screen.getByRole('combobox'));
fireEvent.click(screen.getByTestId('panel-section-create'));
expect(screen.getByTestId('panel-section-name')).toBeInTheDocument();
fireEvent.keyDown(screen.getByTestId('panel-section-name'), {
key: 'Escape',
});
expect(screen.queryByTestId('panel-section-name')).not.toBeInTheDocument();
expect(screen.getByTestId('panel-section-select')).toBeInTheDocument();
});
});
describe('section highlight', () => {
const target = (): unknown => usePanelPickerTargetStore.getState().target;
it('targets the only section without outlining it', () => {
renderDrawer();
expect(target()).toStrictEqual({
layoutIndex: 0,
panelKind: 'signoz/TimeSeriesPanel',
outline: false,
});
});
it('publishes the chosen section and kind while open', () => {
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
renderDrawer({ defaultLayoutIndex: 1 });
fireEvent.click(screen.getByTestId('panel-type-signoz/TablePanel'));
expect(target()).toStrictEqual({
layoutIndex: 1,
panelKind: 'signoz/TablePanel',
outline: true,
});
});
it('drops the target while a new section is being named', () => {
renderDrawer();
fireEvent.click(screen.getByTestId('panel-section-create'));
expect(target()).toBeNull();
});
it('restores the pre-reveal scroll position on cancel', () => {
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
renderDrawer({ defaultLayoutIndex: 1 });
const scrollTo = jest.fn();
// jsdom elements have no scrollTo.
const scroller = { scrollTo } as unknown as HTMLElement;
usePanelPickerTargetStore
.getState()
.rememberScrollOrigin({ element: scroller, top: 120 });
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(scrollTo).toHaveBeenCalledWith({
top: 120,
behavior: 'smooth',
});
expect(target()).toBeNull();
});
it('keeps the scroll position when a panel is added', () => {
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
renderDrawer({ defaultLayoutIndex: 1 });
const scrollTo = jest.fn();
// jsdom elements have no scrollTo.
const scroller = { scrollTo } as unknown as HTMLElement;
usePanelPickerTargetStore
.getState()
.rememberScrollOrigin({ element: scroller, top: 120 });
fireEvent.click(screen.getByTestId('panel-type-confirm'));
expect(scrollTo).not.toHaveBeenCalled();
expect(usePanelPickerTargetStore.getState().scrollOrigin).toBeNull();
});
});
});

View File

@@ -0,0 +1,87 @@
import { PANEL_OPTIONS, type PanelOption } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
export type PanelTypeGroupId =
| 'trends'
| 'compare'
| 'distributions'
| 'single'
| 'raw'
| 'docs';
export const PANEL_TYPE_GROUPS: { id: PanelTypeGroupId; label: string }[] = [
{ id: 'trends', label: 'Trends over time' },
{ id: 'compare', label: 'Compare & rank' },
{ id: 'distributions', label: 'Distributions' },
{ id: 'single', label: 'Single values' },
{ id: 'raw', label: 'Raw records' },
{ id: 'docs', label: 'Documentation' },
];
interface PanelTypeMeta {
group: PanelTypeGroupId;
description: string;
isNew?: boolean;
}
// Total over PanelKind, so a new kind fails to compile until it's placed in a group.
const PANEL_TYPE_META: Record<PanelKind, PanelTypeMeta> = {
'signoz/TimeSeriesPanel': {
group: 'trends',
description: 'Values plotted against time',
},
'signoz/AreaChartPanel': {
group: 'trends',
description: 'Stacked volume over time',
isNew: true,
},
'signoz/BarChartPanel': {
group: 'compare',
description: 'Compare values across categories',
},
'signoz/PieChartPanel': { group: 'compare', description: 'Share of a whole' },
'signoz/HistogramPanel': {
group: 'distributions',
description: 'Distribution of values into buckets',
},
'signoz/NumberPanel': {
group: 'single',
description: 'One aggregate value, large',
},
'signoz/TablePanel': {
group: 'raw',
description: 'Rows and columns of results',
},
'signoz/ListPanel': { group: 'raw', description: 'Raw log and span records' },
'signoz/TextPanel': {
group: 'docs',
description: 'Markdown notes and context',
isNew: true,
},
};
export type PanelTypeItem = PanelOption & Omit<PanelTypeMeta, 'group'>;
export interface PanelTypeGroup {
id: PanelTypeGroupId;
label: string;
items: PanelTypeItem[];
}
/** Every group with the items matching `query` (name, description or group label); empty groups included. */
export function filterPanelTypeGroups(query: string): PanelTypeGroup[] {
const q = query.trim().toLowerCase();
return PANEL_TYPE_GROUPS.map(({ id, label }) => ({
id,
label,
items: PANEL_OPTIONS.filter(({ kind }) => PANEL_TYPE_META[kind].group === id)
.map((option) => ({ ...option, ...PANEL_TYPE_META[option.kind] }))
.filter(
(item) =>
!q ||
item.displayName.toLowerCase().includes(q) ||
item.description.toLowerCase().includes(q) ||
label.toLowerCase().includes(q),
),
}));
}

View File

@@ -0,0 +1,124 @@
import type { PanelKind } from '../../../Panels/types/panelKind';
import PreviewBars from './PreviewBars';
import PreviewRows, { type PreviewCell } from './PreviewRows';
import styles from './PanelTypePreview.module.scss';
// CSS var() doesn't resolve in SVG presentation attributes, so colors go via `style`.
const LINE = { stroke: 'var(--bg-robin-400)' };
const AREA_FILL = { fill: 'var(--bg-robin-500)', fillOpacity: 0.22 };
const RING_TRACK = { stroke: 'var(--l3-background)' };
const RING_PRIMARY = { stroke: 'var(--bg-robin-500)' };
const RING_SECONDARY = { stroke: 'var(--bg-robin-400)', strokeOpacity: 0.55 };
const LINE_PATH =
'M0 34 12 26 24 30 36 16 48 22 60 10 72 18 84 8 96 14 108 5 120 11';
const AREA_PATH = 'M0 30 20 20 40 26 60 12 80 18 100 8 120 14';
const HEAD: PreviewCell = { className: styles.headCell };
const CELL: PreviewCell = { className: styles.cell };
const ACCENT: PreviewCell = { className: styles.accentCell };
const DOT: PreviewCell = { className: styles.dot };
const ACCENT_DOT: PreviewCell = { className: styles.accentDot };
const TABLE_ROWS = [
[HEAD, HEAD, HEAD],
[CELL, CELL, ACCENT],
[CELL, CELL, CELL],
[CELL, CELL, CELL],
];
const LIST_ROWS = [
[ACCENT_DOT, CELL],
[DOT, CELL],
[DOT, CELL],
[DOT, CELL],
];
const TEXT_ROWS = [45, 100, 92, 64].map((width, i) => [
{ ...(i === 0 ? HEAD : CELL), width },
]);
/** Decorative mini-chart per panel kind; total so a new kind needs a sketch. */
export const PANEL_TYPE_PREVIEWS: Record<PanelKind, JSX.Element> = {
'signoz/TimeSeriesPanel': (
<svg viewBox="0 0 120 44" preserveAspectRatio="none" className={styles.svg}>
<path
d={LINE_PATH}
fill="none"
strokeWidth={1.6}
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
style={LINE}
/>
</svg>
),
'signoz/AreaChartPanel': (
<svg viewBox="0 0 120 44" preserveAspectRatio="none" className={styles.svg}>
<path d={`${AREA_PATH}V44H0Z`} style={AREA_FILL} />
<path
d={AREA_PATH}
fill="none"
strokeWidth={1.6}
vectorEffect="non-scaling-stroke"
style={LINE}
/>
</svg>
),
'signoz/BarChartPanel': (
<PreviewBars heights={[80, 58, 44, 30, 18]} fade className={styles.bars} />
),
'signoz/HistogramPanel': (
<PreviewBars
heights={[14, 26, 52, 88, 100, 70, 40, 20, 10]}
fade={false}
className={styles.histogram}
/>
),
'signoz/PieChartPanel': (
<svg viewBox="0 0 44 44" className={styles.svg}>
<circle
cx="22"
cy="22"
r="16"
fill="none"
strokeWidth={8}
style={RING_TRACK}
/>
<circle
cx="22"
cy="22"
r="16"
fill="none"
strokeWidth={8}
strokeDasharray="50 100.5"
transform="rotate(-90 22 22)"
style={RING_PRIMARY}
/>
<circle
cx="22"
cy="22"
r="16"
fill="none"
strokeWidth={8}
strokeDasharray="28 100.5"
strokeDashoffset={-50}
transform="rotate(-90 22 22)"
style={RING_SECONDARY}
/>
</svg>
),
'signoz/NumberPanel': (
<div className={styles.number}>
<span className={styles.numberValue}>
99.7<span className={styles.numberUnit}>%</span>
</span>
<span className={styles.numberLabel}>Availability</span>
</div>
),
'signoz/TablePanel': (
<PreviewRows rows={TABLE_ROWS} rowClassName={styles.tableRow} />
),
'signoz/ListPanel': (
<PreviewRows rows={LIST_ROWS} rowClassName={styles.listRow} />
),
'signoz/TextPanel': <PreviewRows rows={TEXT_ROWS} />,
};

View File

@@ -1,15 +1,17 @@
import type { IconSize } from '@signozhq/icons';
import type { ComponentType, SVGProps } from 'react';
import type { NewPanelTarget } from '../../../patchOps';
type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
size?: number | IconSize;
strokeWidth?: number;
};
export interface SectionOption {
/** The section's `layoutIndex`, stringified for the Select value. */
/** `layoutIndex` stringified, or "root" for a root yet to be created. */
value: string;
layoutIndex: number;
target: NewPanelTarget;
/** Section title, or "Dashboard (root)" for the untitled top-level layout. */
label: string;
/** Caption under the label. */

View File

@@ -0,0 +1,26 @@
import { useEffect } from 'react';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { usePanelPickerTargetStore } from '../../../store/usePanelPickerTargetStore';
export function usePanelPickerDraftSection(
title: string | null,
panelKind: PanelKind,
enabled: boolean,
): void {
const setDraftSection = usePanelPickerTargetStore((s) => s.setDraftSection);
useEffect(() => {
if (enabled) {
setDraftSection(title === null ? null : { title, panelKind });
}
}, [enabled, title, panelKind, setDraftSection]);
// Cleared only on close, so typing updates the preview without unmounting it.
useEffect(() => {
if (!enabled) {
return undefined;
}
return (): void => setDraftSection(null);
}, [enabled, setDraftSection]);
}

View File

@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { usePanelPickerTargetStore } from '../../../store/usePanelPickerTargetStore';
interface UsePanelPickerTargetArgs {
open: boolean;
layoutIndex: number | undefined;
panelKind: PanelKind;
outline: boolean;
}
/** Publishes where the open picker will add its panel, for the dashboard behind the drawer. */
export function usePanelPickerTarget({
open,
layoutIndex,
panelKind,
outline,
}: UsePanelPickerTargetArgs): void {
const setTarget = usePanelPickerTargetStore((s) => s.setTarget);
// Only the open picker writes, so the closed instances mounted per section don't clobber it.
useEffect(() => {
if (open) {
setTarget(
layoutIndex === undefined ? null : { layoutIndex, panelKind, outline },
);
}
}, [open, layoutIndex, panelKind, outline, setTarget]);
useEffect(() => {
if (!open) {
return undefined;
}
return (): void => setTarget(null);
}, [open, setTarget]);
}

View File

@@ -7,33 +7,47 @@ const ROOT_LABEL = 'Dashboard (root)';
const ROOT_DESCRIPTION = 'Top level — no section';
const SECTION_DESCRIPTION = 'Section';
/** Maps dashboard sections to section-picker options (untitled → "root"). */
const NEW_ROOT_VALUE = 'root';
/** Maps dashboard sections to section-picker options; a sectioned dashboard always offers the root. */
export function buildSectionOptions(
sections: DashboardSection[],
): SectionOption[] {
const rootSection = findRootSection(sections);
return sections.map((section) => {
const options: SectionOption[] = sections.map((section) => {
const isRoot = rootSection === section;
return {
value: String(section.layoutIndex),
layoutIndex: section.layoutIndex,
target: { type: 'section', layoutIndex: section.layoutIndex },
label: isRoot ? ROOT_LABEL : (section.title as string),
description: isRoot ? ROOT_DESCRIPTION : SECTION_DESCRIPTION,
isRoot,
Icon: isRoot ? LayoutDashboard : Rows2,
};
});
if (!rootSection && sections.some((section) => section.title)) {
options.unshift({
value: NEW_ROOT_VALUE,
target: { type: 'root' },
label: ROOT_LABEL,
description: ROOT_DESCRIPTION,
isRoot: true,
Icon: LayoutDashboard,
});
}
return options;
}
/**
* Picks the option the picker should open on: the section the "Add panel" was
* triggered from when present and still valid, otherwise the first option.
* triggered from when present and still valid, otherwise the dashboard root.
*/
export function resolveDefaultSectionValue(
options: SectionOption[],
defaultLayoutIndex: number | undefined,
): string {
const fallback = options[0]?.value ?? '';
const fallback =
(options.find((option) => option.isRoot) ?? options[0])?.value ?? '';
if (defaultLayoutIndex === undefined) {
return fallback;
}

View File

@@ -0,0 +1,29 @@
.draftSection {
margin-bottom: 12px;
border: 1px solid var(--l1-border);
border-radius: 4px;
outline: 1px dashed var(--bg-robin-500);
outline-offset: 2px;
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
color: var(--l2-foreground);
}
.title {
color: var(--l1-foreground);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.placeholder {
color: var(--l3-foreground);
font-weight: 500;
}

View File

@@ -0,0 +1,45 @@
import { ChevronDown } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { NEW_PANEL_SIZE } from '../../../patchOps';
import { usePanelPickerTargetStore } from '../../../store/usePanelPickerTargetStore';
import {
GRID_MARGIN,
gridItemHeight,
gridItemWidth,
} from '../SectionGrid/gridMetrics';
import NewPanelPlaceholder from '../SectionGrid/NewPanelPlaceholder';
import styles from './DraftSection.module.scss';
function DraftSection(): JSX.Element | null {
const draft = usePanelPickerTargetStore((s) => s.draftSection);
if (draft === null) {
return null;
}
const title = draft.title.trim();
return (
<div className={styles.draftSection} data-testid="draft-section">
<div className={styles.header}>
<ChevronDown size={14} />
<Typography.Text className={title ? styles.title : styles.placeholder}>
{title || 'New section'}
</Typography.Text>
</div>
<div style={{ padding: GRID_MARGIN }}>
<div
style={{
width: gridItemWidth(NEW_PANEL_SIZE.width),
height: gridItemHeight(NEW_PANEL_SIZE.height),
}}
>
<NewPanelPlaceholder kind={draft.panelKind} />
</div>
</div>
</div>
);
}
export default DraftSection;

View File

@@ -0,0 +1,54 @@
import { act, render, screen } from '@testing-library/react';
import { usePanelPickerTargetStore } from '../../../../store/usePanelPickerTargetStore';
import DraftSection from '../DraftSection';
jest.mock('../../../../Panels/registry', () => ({
getPanelDefinition: (): { displayName: string } => ({ displayName: 'Table' }),
}));
describe('DraftSection', () => {
const scrollTo = jest.fn();
const setDraft = (title: string): void =>
usePanelPickerTargetStore
.getState()
.setDraftSection({ title, panelKind: 'signoz/TablePanel' });
beforeEach(() => {
usePanelPickerTargetStore.getState().reset();
scrollTo.mockClear();
// jsdom elements have no scrollTo.
Object.defineProperty(document.documentElement, 'scrollTo', {
value: scrollTo,
configurable: true,
});
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
return 0;
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders nothing while no section is being created', () => {
render(<DraftSection />);
expect(screen.queryByTestId('draft-section')).not.toBeInTheDocument();
});
it('previews the typed name with the new panel, scrolling to it once', () => {
render(<DraftSection />);
act(() => setDraft(''));
expect(screen.getByText('New section')).toBeInTheDocument();
expect(screen.getByTestId('new-panel-placeholder')).toHaveTextContent(
'Table',
);
act(() => setDraft('Errors'));
expect(screen.getByText('Errors')).toBeInTheDocument();
expect(scrollTo).toHaveBeenCalledTimes(1);
});
});

View File

@@ -4,6 +4,12 @@
border-radius: 4px;
}
.pickerTarget {
border-radius: 4px;
outline: 1px dashed var(--bg-robin-500);
outline-offset: 2px;
}
.dragging {
opacity: 0.8;
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useRef, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import cx from 'classnames';
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
@@ -9,6 +10,8 @@ import type { DashboardSection } from '../../../utils';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useCloneSection } from '../hooks/useCloneSection';
import { useDeleteSection } from '../hooks/useDeleteSection';
import { usePanelPickerHighlight } from '../hooks/usePanelPickerHighlight';
import { usePanelPickerReveal } from '../hooks/usePanelPickerReveal';
import { useRenameSection } from '../hooks/useRenameSection';
import { useScrollIntoView } from '../hooks/useScrollIntoView';
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
@@ -67,12 +70,17 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
const sectionRef = useRef<HTMLDivElement>(null);
useScrollIntoView(section.id, sectionRef);
const pickerTarget = usePanelPickerHighlight(section.layoutIndex);
const isOutlined = !!pickerTarget?.outline;
// A collapsed section has no grid to show the placeholder in, so reveal its header.
usePanelPickerReveal(sectionRef, !!pickerTarget && !!section.title && !open);
const grid = (
<SectionGrid
items={section.items}
layoutIndex={section.layoutIndex}
sections={sections}
placeholderKind={pickerTarget?.panelKind}
/>
);
@@ -81,6 +89,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
return (
<div
ref={sectionRef}
className={cx({ [styles.pickerTarget]: isOutlined })}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
@@ -92,7 +101,9 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
return (
<div
ref={sectionRef}
className={styles.section}
className={cx(styles.section, {
[styles.pickerTarget]: isOutlined,
})}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
@@ -113,7 +124,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
}}
/>
{open &&
(section.items.length > 0 ? (
(section.items.length > 0 || pickerTarget ? (
grid
) : (
<div className={styles.emptySection}>

View File

@@ -0,0 +1,23 @@
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
height: 100%;
box-sizing: border-box;
border: 1px dashed var(--bg-robin-500);
border-radius: 6px;
background: color-mix(in srgb, var(--bg-robin-500) 6%, transparent);
}
.name {
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
}
.label {
color: var(--bg-robin-400);
font-size: 11px;
}

View File

@@ -0,0 +1,25 @@
import { useRef } from 'react';
import { getPanelDefinition } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { usePanelPickerReveal } from '../hooks/usePanelPickerReveal';
import styles from './NewPanelPlaceholder.module.scss';
function NewPanelPlaceholder({ kind }: { kind: PanelKind }): JSX.Element {
const ref = useRef<HTMLDivElement>(null);
usePanelPickerReveal(ref, true);
return (
<div
ref={ref}
className={styles.placeholder}
data-testid="new-panel-placeholder"
>
<span className={styles.name}>{getPanelDefinition(kind).displayName}</span>
<span className={styles.label}>New panel</span>
</div>
);
}
export default NewPanelPlaceholder;

View File

@@ -1,4 +1,9 @@
.grid {
// RGL animates height, which would cut the placeholder's reveal short.
&.instantHeight {
transition: none;
}
// Override react-grid-layout's default red drag/resize placeholder with the
// SigNoz brand blue.
:global(.react-grid-item.react-grid-placeholder) {

View File

@@ -1,47 +1,60 @@
import { useMemo } from 'react';
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
import cx from 'classnames';
import { newPanelSlot } from '../../../patchOps';
import type { PanelKind } from '../../../Panels/types/panelKind';
import type { DashboardSection } from '../../../utils';
import { usePersistLayout } from '../hooks/usePersistLayout';
import { GRID_MARGIN, GRID_ROW_HEIGHT } from './gridMetrics';
import NewPanelPlaceholder from './NewPanelPlaceholder';
import SectionGridItem from './SectionGridItem';
import styles from './SectionGrid.module.scss';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
const ResponsiveGridLayout = WidthProvider(GridLayout);
const PLACEHOLDER_ID = '__new-panel-placeholder';
interface SectionGridProps {
items: DashboardSection['items'];
layoutIndex: number;
/** All sections — layout context for the panel menu's move/delete actions. */
sections?: DashboardSection[];
/** Shows where a new panel of this kind will land. */
placeholderKind?: PanelKind;
}
function SectionGrid({
items,
layoutIndex,
sections,
placeholderKind,
}: SectionGridProps): JSX.Element {
const { isEditable } = useDashboardEditContext();
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({
i: item.id,
x: item.x,
y: item.y,
w: item.width,
h: item.height,
})),
[items],
);
const rglLayout = useMemo<Layout[]>(() => {
const layout: Layout[] = items.map((item) => ({
i: item.id,
x: item.x,
y: item.y,
w: item.width,
h: item.height,
}));
if (placeholderKind) {
const { x, y, width, height } = newPanelSlot(items);
layout.push({ i: PLACEHOLDER_ID, x, y, w: width, h: height, static: true });
}
return layout;
}, [items, placeholderKind]);
const { handleLayoutChange } = usePersistLayout({ layoutIndex, items });
return (
<ResponsiveGridLayout
className={styles.grid}
className={cx(styles.grid, { [styles.instantHeight]: !!placeholderKind })}
cols={12}
rowHeight={45}
rowHeight={GRID_ROW_HEIGHT}
autoSize
useCSSTransforms
layout={rglLayout}
@@ -51,7 +64,7 @@ function SectionGrid({
isResizable={isEditable}
onDragStop={handleLayoutChange}
onResizeStop={handleLayoutChange}
margin={[8, 8]}
margin={[GRID_MARGIN, GRID_MARGIN]}
>
{items.map((item) => (
// A layout item can reference a panel id that no longer exists in the
@@ -74,6 +87,11 @@ function SectionGrid({
)}
</div>
))}
{placeholderKind && (
<div key={PLACEHOLDER_ID}>
<NewPanelPlaceholder kind={placeholderKind} />
</div>
)}
</ResponsiveGridLayout>
);
}

View File

@@ -0,0 +1,42 @@
import { render, screen } from '@testing-library/react';
import { usePanelPickerTargetStore } from '../../../../store/usePanelPickerTargetStore';
import NewPanelPlaceholder from '../NewPanelPlaceholder';
jest.mock('../../../../Panels/registry', () => ({
getPanelDefinition: (): { displayName: string } => ({ displayName: 'Table' }),
}));
describe('NewPanelPlaceholder', () => {
const scrollTo = jest.fn();
beforeEach(() => {
usePanelPickerTargetStore.getState().reset();
scrollTo.mockClear();
// jsdom elements have no scrollTo.
Object.defineProperty(document.documentElement, 'scrollTo', {
value: scrollTo,
configurable: true,
});
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
return 0;
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('names the kind and scrolls to it, recording the prior scroll position', () => {
render(<NewPanelPlaceholder kind="signoz/TablePanel" />);
expect(screen.getByTestId('new-panel-placeholder')).toHaveTextContent(
'TableNew panel',
);
expect(scrollTo).toHaveBeenCalledWith(
expect.objectContaining({ behavior: 'smooth' }),
);
expect(usePanelPickerTargetStore.getState().scrollOrigin).not.toBeNull();
});
});

View File

@@ -0,0 +1,12 @@
import { gridItemHeight, gridItemWidth } from '../gridMetrics';
describe('gridMetrics', () => {
it('sizes a half-width, six-row item like react-grid-layout', () => {
expect(gridItemWidth(6)).toBe('calc(50% - 4px)');
expect(gridItemHeight(6)).toBe(310);
});
it('spans the full padded width at every column', () => {
expect(gridItemWidth(12)).toBe('calc(100% + 0px)');
});
});

View File

@@ -0,0 +1,16 @@
import { GRID_COLS } from '../../../patchOps';
export const GRID_ROW_HEIGHT = 45;
/** Gap between items, also used as the grid's container padding. */
export const GRID_MARGIN = 8;
/** CSS width of an item spanning `cols` columns, inside the grid's container padding. */
export function gridItemWidth(cols: number): string {
const fraction = cols / GRID_COLS;
const offset = (cols - 1 - fraction * (GRID_COLS - 1)) * GRID_MARGIN;
return `calc(${fraction * 100}% ${offset < 0 ? '-' : '+'} ${Math.abs(offset)}px)`;
}
export function gridItemHeight(rows: number): number {
return rows * GRID_ROW_HEIGHT + (rows - 1) * GRID_MARGIN;
}

View File

@@ -0,0 +1,26 @@
import { getScrollParent } from '../scrollUtils';
describe('getScrollParent', () => {
it('picks the OverlayScrollbars viewport before it has marked itself scrollable', () => {
const viewport = document.createElement('div');
viewport.setAttribute('data-overlayscrollbars-viewport', '');
const child = document.createElement('div');
viewport.appendChild(child);
document.body.appendChild(viewport);
expect(getScrollParent(child)).toBe(viewport);
viewport.remove();
});
it('falls back to the document scroller', () => {
const child = document.createElement('div');
document.body.appendChild(child);
expect(getScrollParent(child)).toBe(
document.scrollingElement ?? document.documentElement,
);
child.remove();
});
});

View File

@@ -0,0 +1,33 @@
/** Nearest ancestor that scrolls vertically, falling back to the document scroller. */
export function getScrollParent(element: HTMLElement): HTMLElement {
let node = element.parentElement;
while (node) {
// OverlayScrollbars marks its viewport scrollable only after it notices new overflow.
if (node.hasAttribute('data-overlayscrollbars-viewport')) {
return node;
}
const { overflowY } = getComputedStyle(node);
if (
(overflowY === 'auto' || overflowY === 'scroll') &&
node.scrollHeight > node.clientHeight
) {
return node;
}
node = node.parentElement;
}
return (document.scrollingElement as HTMLElement) ?? document.documentElement;
}
/** `scrollTop` for `scroller` that centers `element` vertically in its visible area. */
export function centeredScrollTop(
scroller: HTMLElement,
element: HTMLElement,
): number {
const isDocument = scroller === document.scrollingElement;
const viewTop = isDocument ? 0 : scroller.getBoundingClientRect().top;
const viewHeight = isDocument ? window.innerHeight : scroller.clientHeight;
const rect = element.getBoundingClientRect();
return (
scroller.scrollTop + rect.top - viewTop - (viewHeight - rect.height) / 2
);
}

View File

@@ -0,0 +1,13 @@
import {
type PanelPickerTarget,
usePanelPickerTargetStore,
} from '../../../store/usePanelPickerTargetStore';
/** The open new-panel picker's target when it's this section, else null. */
export function usePanelPickerHighlight(
layoutIndex: number,
): PanelPickerTarget | null {
return usePanelPickerTargetStore((s) =>
s.target?.layoutIndex === layoutIndex ? s.target : null,
);
}

View File

@@ -0,0 +1,34 @@
import { RefObject, useEffect } from 'react';
import { usePanelPickerTargetStore } from '../../../store/usePanelPickerTargetStore';
import { centeredScrollTop, getScrollParent } from './scrollUtils';
/** Centers the element when `active` turns on, recording the prior position for a dismissed picker. */
export function usePanelPickerReveal(
ref: RefObject<HTMLElement>,
active: boolean,
): void {
const rememberScrollOrigin = usePanelPickerTargetStore(
(s) => s.rememberScrollOrigin,
);
useEffect(() => {
if (!active) {
return undefined;
}
// A frame later the grid has placed the element and the opening drawer can't cancel the scroll.
const frame = requestAnimationFrame(() => {
const element = ref.current;
if (!element) {
return;
}
const scroller = getScrollParent(element);
rememberScrollOrigin({ element: scroller, top: scroller.scrollTop });
scroller.scrollTo({
top: centeredScrollTop(scroller, element),
behavior: 'smooth',
});
});
return (): void => cancelAnimationFrame(frame);
}, [active, ref, rememberScrollOrigin]);
}

View File

@@ -7,6 +7,7 @@ import type {
import { layoutsToSections } from '../utils';
import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
import DraftSection from './Section/DraftSection/DraftSection';
import { useViewPanel } from './Panel/hooks/useViewPanel';
import ViewPanelModal from './Panel/ViewPanelModal/ViewPanelModal';
import Section from './Section/Section/Section';
@@ -72,6 +73,7 @@ function PanelsAndSectionsLayout({
return (
<div className={styles.body}>
{renderContent()}
<DraftSection />
<ViewPanelModal
open={!!expandedPanel}
panel={expandedPanel}

View File

@@ -12,7 +12,9 @@ import {
createPanelOps,
findFreeSlot,
itemsOverlap,
newPanelSlot,
setPanelTextOp,
titleLooseLayoutsOps,
} from '../patchOps';
function item(y: number, height: number): DashboardGridItemDTO {
@@ -28,6 +30,10 @@ function itemAt(
return { x, y, width, height, content: { $ref: '#/spec/panels/x' } };
}
function untitled(items: DashboardGridItemDTO[]): DashboardtypesLayoutDTO {
return { kind: 'Grid', spec: { items } } as DashboardtypesLayoutDTO;
}
function section(items: DashboardGridItemDTO[]): DashboardtypesLayoutDTO {
return {
kind: 'Grid',
@@ -50,7 +56,12 @@ describe('createPanelOps', () => {
it('adds the panel + a grid item in the requested section', () => {
const layouts = [section([item(0, 6)]), section([])];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 0 },
panelId: 'p1',
panel,
});
expect(ops).toHaveLength(2);
expect(ops[0]).toMatchObject({ op: 'add', path: '/spec/panels/p1' });
@@ -63,10 +74,78 @@ describe('createPanelOps', () => {
);
});
it('creates the requested new section and places the panel in it', () => {
const layouts = [section([item(0, 6)])];
const ops = createPanelOps({
layouts,
target: { type: 'newSection', title: ' Errors ' },
panelId: 'p1',
panel,
});
expect(ops[0]).toMatchObject({ op: 'add', path: '/spec/layouts/-' });
expect(ops[0].value).toMatchObject({
spec: { display: { title: 'Errors' } },
});
expect(ops[2]).toMatchObject({ path: '/spec/layouts/1/spec/items/-' });
expect(ops[2].value).toMatchObject({ x: 0, y: 0 });
});
it('titles loose panels before creating a section on a free-flowing dashboard', () => {
const layouts = [untitled([item(0, 6)])];
const ops = createPanelOps({
layouts,
target: { type: 'newSection', title: 'Errors' },
panelId: 'p1',
panel,
});
expect(ops[0]).toMatchObject({
path: '/spec/layouts/0/spec/display',
value: { title: 'Section 1' },
});
expect(ops[1]).toMatchObject({ path: '/spec/layouts/-' });
expect(ops[3]).toMatchObject({ path: '/spec/layouts/1/spec/items/-' });
});
it('inserts a root ahead of the sections when there is none', () => {
const layouts = [section([item(0, 6)])];
const ops = createPanelOps({
layouts,
target: { type: 'root' },
panelId: 'p1',
panel,
});
expect(ops[0]).toMatchObject({ op: 'add', path: '/spec/layouts/0' });
expect(ops[0].value).toMatchObject({ spec: { items: [] } });
expect(ops[2]).toMatchObject({ path: '/spec/layouts/0/spec/items/-' });
expect(ops[2].value).toMatchObject({ x: 0, y: 0 });
});
it('adds to the existing root', () => {
const layouts = [untitled([item(0, 6)]), section([])];
const ops = createPanelOps({
layouts,
target: { type: 'root' },
panelId: 'p1',
panel,
});
expect(ops).toHaveLength(2);
expect(ops[1]).toMatchObject({ path: '/spec/layouts/0/spec/items/-' });
expect(ops[1].value).toMatchObject({ x: 6, y: 0 });
});
it('fills the empty right half of a row instead of wrapping to a new one', () => {
// Left half filled → new 6-wide panel fits at x:6 in the same row.
const layouts = [section([item(0, 6)])];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 0 },
panelId: 'p1',
panel,
});
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(6);
@@ -76,7 +155,12 @@ describe('createPanelOps', () => {
it('wraps to a new row when the last row is full', () => {
// Full-width (12) row leaves no room → panel drops to the next row.
const layouts = [section([itemAt(0, 0, 12, 6)])];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 0 },
panelId: 'p1',
panel,
});
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(0);
@@ -86,7 +170,12 @@ describe('createPanelOps', () => {
it('ignores a gap in an upper row and only fills the last row', () => {
// Upper-row gap is ignored when the last row is full → starts a fresh row.
const layouts = [section([itemAt(0, 0, 6, 6), itemAt(0, 6, 12, 6)])];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 0 },
panelId: 'p1',
panel,
});
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(0);
@@ -96,7 +185,12 @@ describe('createPanelOps', () => {
it('fills the right of the last row when it has room', () => {
// Half-filled last row → panel sits at x:6 of that row.
const layouts = [section([itemAt(0, 0, 12, 6), itemAt(0, 6, 6, 6)])];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 0 },
panelId: 'p1',
panel,
});
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(6);
@@ -109,7 +203,12 @@ describe('createPanelOps', () => {
section([itemAt(0, 0, 6, 6)]),
section([itemAt(0, 0, 12, 6)]),
];
const ops = createPanelOps({ layouts, layoutIndex: 1, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 1 },
panelId: 'p1',
panel,
});
expect(ops[1].path).toBe('/spec/layouts/1/spec/items/-');
const value = ops[1].value as DashboardGridItemDTO;
@@ -121,7 +220,6 @@ describe('createPanelOps', () => {
const layouts = [section([]), section([item(0, 6)])];
const ops = createPanelOps({
layouts,
layoutIndex: undefined,
panelId: 'p1',
panel,
});
@@ -131,14 +229,18 @@ describe('createPanelOps', () => {
it('falls back to the root (first) section when the requested index is out of range', () => {
const layouts = [section([item(0, 6)]), section([])];
const ops = createPanelOps({ layouts, layoutIndex: 5, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 5 },
panelId: 'p1',
panel,
});
expect(ops[1].path).toBe('/spec/layouts/0/spec/items/-');
});
it('creates a section first when the dashboard has none', () => {
const ops = createPanelOps({
layouts: [],
layoutIndex: undefined,
panelId: 'p1',
panel,
});
@@ -161,7 +263,12 @@ describe('createPanelOps', () => {
itemAt(0, 6, 3, 6),
]),
];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const ops = createPanelOps({
layouts,
target: { type: 'section', layoutIndex: 0 },
panelId: 'p1',
panel,
});
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(0);
@@ -273,3 +380,35 @@ describe('setPanelTextOp', () => {
expect(setPanelTextOp('p1', '').op).toBe(DashboardtypesPatchOpDTO.add);
});
});
describe('titleLooseLayoutsOps', () => {
it('titles only untitled layouts that hold panels', () => {
const ops = titleLooseLayoutsOps([
untitled([item(0, 6)]),
untitled([]),
untitled([item(0, 6)]),
]);
expect(ops.map((op) => [op.path, op.value])).toStrictEqual([
['/spec/layouts/0/spec/display', { title: 'Section 1' }],
['/spec/layouts/2/spec/display', { title: 'Section 2' }],
]);
});
it('does nothing once the dashboard has a titled section', () => {
expect(
titleLooseLayoutsOps([untitled([item(0, 6)]), section([])]),
).toStrictEqual([]);
});
});
describe('newPanelSlot', () => {
it('sizes the free slot like a saved new panel', () => {
expect(newPanelSlot([item(0, 6)])).toStrictEqual({
x: 6,
y: 0,
width: 6,
height: 6,
});
});
});

View File

@@ -35,7 +35,10 @@ describe('useCreatePanel', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useCreatePanel());
act(() => {
result.current.createPanel('timeSeries' as never, 2);
result.current.createPanel('timeSeries' as never, {
type: 'section',
layoutIndex: 2,
});
});
const [url] = mockSafeNavigate.mock.calls[0];
@@ -45,6 +48,19 @@ describe('useCreatePanel', () => {
expect(url).toContain('relativeTime=6h');
});
it('carries a new section title for the editor to create on save', () => {
const { result } = renderHook(() => useCreatePanel());
act(() => {
result.current.createPanel('timeSeries' as never, {
type: 'newSection',
title: 'Errors',
});
});
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('newSection=Errors');
});
it('carries a custom absolute window and never a stray relativeTime', () => {
mockGlobalTime = {
selectedTime: 'custom',
@@ -53,7 +69,10 @@ describe('useCreatePanel', () => {
};
const { result } = renderHook(() => useCreatePanel());
act(() => {
result.current.createPanel('timeSeries' as never, 2);
result.current.createPanel('timeSeries' as never, {
type: 'section',
layoutIndex: 2,
});
});
const [url] = mockSafeNavigate.mock.calls[0];

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react';
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
import type { NewPanelTarget } from '../patchOps';
import type { PanelKind } from '../Panels/types/panelKind';
import { clearColumnWidths } from '../Panels/utils/columnWidthStorage';
import { useOpenPanelEditor } from './useOpenPanelEditor';
@@ -12,8 +13,8 @@ interface UseCreatePanelResult {
closePicker: () => void;
/** The section the picker was opened against — seeds its section dropdown. */
targetLayoutIndex: number | undefined;
/** `layoutIndex` overrides the opened-against target (the dropdown's choice). */
createPanel: (panelKind: PanelKind, layoutIndex?: number) => void;
/** `target` overrides the opened-against section (the picker's choice). */
createPanel: (panelKind: PanelKind, target?: NewPanelTarget) => void;
}
/**
@@ -38,9 +39,13 @@ export function useCreatePanel(): UseCreatePanelResult {
}, []);
const createPanel = useCallback(
(panelKind: PanelKind, targetIndex?: number): void => {
(panelKind: PanelKind, pickedTarget?: NewPanelTarget): void => {
setIsPickerOpen(false);
const target = targetIndex ?? layoutIndex;
const target =
pickedTarget ??
(layoutIndex === undefined
? undefined
: { type: 'section' as const, layoutIndex });
// Every draft shares the sentinel id, so an abandoned draft's column widths
// would otherwise seed this one.
clearColumnWidths(NEW_PANEL_ID);

View File

@@ -154,16 +154,22 @@ export function addPanelToSectionOps({
];
}
export type NewPanelTarget =
| { type: 'section'; layoutIndex: number }
| { type: 'newSection'; title: string }
/** Created at the top when missing. */
| { type: 'root' };
interface CreatePanelOpsArgs {
/** Current sections, used to resolve the target and the next free row. */
layouts: DashboardtypesLayoutDTO[];
/** Preferred section (from a section's "Add panel" trigger); falls back to the root (first) section. */
layoutIndex: number | undefined;
/** Omitted, or a stale section → the first section. */
target?: NewPanelTarget;
panelId: string;
panel: DashboardtypesPanelDTO;
}
const NEW_PANEL_SIZE = { width: 6, height: 6 };
export const NEW_PANEL_SIZE = { width: 6, height: 6 };
/** Columns in the section grid — mirrors `cols` on SectionGrid's GridLayout. */
export const GRID_COLS = 12;
@@ -240,14 +246,18 @@ export function findFreeSlot(
return bottomRowSlot(items);
}
/** Where a brand-new panel lands in a section: its free slot plus the default size. */
export function newPanelSlot(items: PlacedItem[]): Required<PlacedItem> {
return { ...findFreeSlot(items, NEW_PANEL_SIZE.width), ...NEW_PANEL_SIZE };
}
/**
* Ops to persist a brand-new panel (editor save path): resolve the target
* section (requested index if valid, else the root/first section, else a
* freshly-created one) and place the panel via `findFreeSlot`.
* section, creating it when needed, and place the panel via `findFreeSlot`.
*/
export function createPanelOps({
layouts,
layoutIndex,
target,
panelId,
panel,
}: CreatePanelOpsArgs): DashboardtypesJSONPatchOperationDTO[] {
@@ -255,10 +265,23 @@ export function createPanelOps({
let targetIndex: number;
let items: DashboardGridItemDTO[];
if (layoutIndex !== undefined && layouts[layoutIndex] !== undefined) {
const newSectionTitle =
target?.type === 'newSection' ? target.title.trim() : '';
if (newSectionTitle) {
ops.push(...titleLooseLayoutsOps(layouts), addSectionOp(newSectionTitle));
targetIndex = layouts.length;
items = [];
} else if (target?.type === 'root' && layouts[0]?.spec?.display?.title) {
ops.push({ op: add, path: '/spec/layouts/0', value: newGridLayout('') });
targetIndex = 0;
items = [];
} else if (
target?.type === 'section' &&
layouts[target.layoutIndex] !== undefined
) {
// Explicit section — a section's own "New Panel" trigger.
targetIndex = layoutIndex;
items = layouts[layoutIndex]?.spec.items ?? [];
targetIndex = target.layoutIndex;
items = layouts[target.layoutIndex]?.spec.items ?? [];
} else if (layouts.length > 0) {
// No section specified (toolbar "New Panel") → the root (first) section.
targetIndex = 0;
@@ -270,16 +293,13 @@ export function createPanelOps({
items = [];
}
const { x, y } = findFreeSlot(items, NEW_PANEL_SIZE.width);
ops.push(
...addPanelToSectionOps({
panelId,
panel,
layoutIndex: targetIndex,
item: {
x,
y,
...NEW_PANEL_SIZE,
...newPanelSlot(items),
content: { $ref: panelRef(panelId) },
},
}),
@@ -334,6 +354,22 @@ export function titleUntitledSectionOp(
};
}
/** Titles loose layouts ("Section 1", …) on a free-flowing dashboard, as panels can't stay loose once a section exists. */
export function titleLooseLayoutsOps(
layouts: DashboardtypesLayoutDTO[],
): DashboardtypesJSONPatchOperationDTO[] {
if (layouts.some((layout) => layout.spec?.display?.title)) {
return [];
}
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
layouts.forEach((layout, index) => {
if ((layout.spec?.items ?? []).length > 0) {
ops.push(titleUntitledSectionOp(index, `Section ${ops.length + 1}`));
}
});
return ops;
}
/** Remove a section. Panel cleanup (orphaned refs) is handled by the caller. */
export function removeSectionOp(
layoutIndex: number,

View File

@@ -0,0 +1,72 @@
import { create } from 'zustand';
import type { PanelKind } from '../Panels/types/panelKind';
export interface ScrollOrigin {
element: HTMLElement;
top: number;
}
export interface PanelPickerTarget {
layoutIndex: number;
panelKind: PanelKind;
/** Outline the section; off when there's only one place to add to. */
outline: boolean;
}
export interface PanelPickerDraftSection {
title: string;
panelKind: PanelKind;
}
/**
* Where the open new-panel picker will add its panel, so the dashboard can mark the
* section and preview the slot behind the drawer. `scrollOrigin` is the scroll
* position before the first reveal, restored if the picker is dismissed.
*/
export interface PanelPickerTargetStore {
target: PanelPickerTarget | null;
/** Section the picker will create; null when not creating one. */
draftSection: PanelPickerDraftSection | null;
scrollOrigin: ScrollOrigin | null;
setTarget: (target: PanelPickerTarget | null) => void;
setDraftSection: (draft: PanelPickerDraftSection | null) => void;
/** No-op once an origin is recorded, so later reveals keep the first position. */
rememberScrollOrigin: (origin: ScrollOrigin) => void;
reset: () => void;
}
export const usePanelPickerTargetStore = create<PanelPickerTargetStore>(
(set, get) => ({
target: null,
draftSection: null,
scrollOrigin: null,
setTarget: (target): void => {
set({ target });
},
setDraftSection: (draftSection): void => {
set({ draftSection });
},
rememberScrollOrigin: (origin): void => {
if (!get().scrollOrigin) {
set({ scrollOrigin: origin });
}
},
reset: (): void => {
set({
target: null,
draftSection: null,
scrollOrigin: null,
});
},
}),
);
/** Clears the target; with `restoreScroll`, scrolls back to where the dashboard was before any reveal. */
export function releasePanelPickerTarget(restoreScroll: boolean): void {
const { scrollOrigin, reset } = usePanelPickerTargetStore.getState();
if (restoreScroll && scrollOrigin) {
scrollOrigin.element.scrollTo({ top: scrollOrigin.top, behavior: 'smooth' });
}
reset();
}

View File

@@ -23,7 +23,7 @@ import PanelEditorContainer from '../DashboardContainer/PanelEditor';
import type { PanelEditorHandoffState } from '../DashboardContainer/PanelEditor/panelEditorHandoff';
import {
parseNewPanelKind,
parseNewPanelLayoutIndex,
parseNewPanelTarget,
} from '../DashboardContainer/PanelEditor/newPanelRoute';
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
import { useTimeSearchParams } from '../DashboardContainer/hooks/useTimeSearchParams';
@@ -99,7 +99,7 @@ function PanelEditorPage(): JSX.Element {
}, [newKind, existingPanel, handoffSpec]);
// Target section for a newly-created panel (set by the "Add panel" trigger).
const layoutIndex = parseNewPanelLayoutIndex(search);
const target = parseNewPanelTarget(search);
const backToDashboard = useCallback((): void => {
// Drop editor-only URL state (variables come from the persisted store), but carry
@@ -137,7 +137,7 @@ function PanelEditorPage(): JSX.Element {
panel={panel}
savedPanel={existingPanel}
isNew={!!newKind}
layoutIndex={layoutIndex}
target={target}
onClose={backToDashboard}
onSaved={backToDashboard}
/>